オブジェクトを行列で回転させる
今回のサンプルの実行結果
レイマーチングの行列処理
前回はオブジェクト同士を補間しながらスムーズに結合する方法について解説しました。
見た目の自然さがグッと増すことで、クオリティの高いレンダリング結果を得ることができるテクニックでした。
さて今回ですが、WebGL のプログラムをはじめとする 3D プログラミングではおなじみと言ってもいい行列による座標変換を GLSL のレイマーチングに導入してみましょう。これを習得すれば、任意の回転を加えたオブジェクトを自由にレンダリングできるようになります。
通常の WebGL のプログラミングでは、行列の処理をサポートするライブラリを使うなどして、javascript 側でも行列処理を行うのが一般的です。むしろ行列の生成は javascript 側に任せて、シェーダ側では uniform 変数として行列を受け取り、最終的な加工だけを行うことが多いかもしれません。
レイマーチングの場合は、このあたりも GLSL 内部ですべて完結するようにシェーダを記述することになります。もちろん、javascript 側からシェーダに送る方法を用いてはダメというわけではないのですが、ここはひとつ、シェーダ側だけでやってみることにしましょう。
行列処理は難しい?
さてこのテキストを読んでいる方々が、どのようなスキルをお持ちなのかはわかりませんが……一般に、行列はすごく難しいものというイメージを持っている人が多いと思います。
実際、行列の処理はライブラリにまかせっきりで、その中身で何をやっているのかはいまいち理解していない、という人もいると思います。何を隠そう私自身も、実はそれと似たようなものなのです。行列処理の隅々まで知り尽くしているかと言えば、けしてそんなことはありません。
以前に WebGL カテゴリのテキストでも書きましたが、3D プログラミングに不慣れなうちは、行列処理の詳細な計算方法までを理解しようとするよりも、行列の種類や名前をしっかり覚えたり、それを利用することでどんなレンダリング結果が得られるのかを把握したりするほうが、よほど身のためになります。
しかし、行列について理解が深まっていれば、当然より汎用的なプログラムを組むことが可能になりますし、見た目にも、また実行速度的にも、優れたプログラムを書くことができるようになります。
今回は GLSL だけで行列を扱うわけですが、最初はその処理の意味がよくわからなくても、要はコピペでコードを動かすだけでも全然問題ないと個人的には思います。ただし、もっとレベルアップしたいのであれば、これを機会にしっかりと行列を勉強してみるのもいいと思います。平行移動や拡大縮小、回転くらいまでならそれほど難しくないと思います。
まずはコピーしたもので構いませんので動かしてみましょう。
そして、なぜ? どうして? を大事にしつつ、自分を磨いていきましょう。
今回のサンプルで使う distance function
冒頭のサンプル実行結果画像を見ると、前回のトーラス+ボックスのレンダリング結果に加え、なにやらボタン電池のような、円柱形のオブジェクトが追加されているのがわかると思います。
行列について触れる前に、まずこの初登場の distance function から見てみましょう。
シリンダーの distance function
float distFuncCylinder(vec3 p, vec2 r){
vec2 d = abs(vec2(length(p.xy), p.z)) - r;
return min(max(d.x, d.y), 0.0) + length(max(d, 0.0)) - 0.1;
}
この関数は引数をふたつ取ります。第一引数はいつものようにレイの先端の座標を渡します。第二引数は vec2
型になっていますが、これはシリンダー、つまり円柱の大きさを指定するためのパラメータになります。
トーラスの distance function の解説をしたときにも触れましたが、最初の行で length
で処理しているふたつの座標軸を平面として、円形にオブジェクトが展開されます。今回の場合だと、XY 平面に円形を描くような形になるわけですね。
そしてこの式をよく見ればわかると思いますが、引数の r
には、一番目の要素に円柱の太さ、二番目の要素に円柱の長さが入ることになります。断面の円の半径が 1 で長さが 3 の円柱なら vec2(1.0, 3.0)
というように指定すればいいわけですね。
そして関数内の二行目では min
と max
を使って値を正しい範囲に収まるように補正しています。ちなみに、 min(max(d.x, d.y), 0.0)
の部分を削除しても、実は円柱はおおよそ正しくレンダリングされます。ただし座標の値によってはノイズのように計算がうまくいかない場合があるのでこの部分を付加しています。
また、最後に 0.1 を減算する処理が入っていますが、これはボックスモデルの角を丸くしたときとまったく同じ概念です。もしこの 0.1 をマイナスする部分がよくわからない人は過去のテキストを見てみるといいと思います。
回転行列を生成する
続いてはオブジェクトを回転させるために、行列を用いた処理を書いていきます。
とは言っても、先ほども書いたように、概念が難しければとりあえずコピーして使ってみるというのもひとつの手です。そして、行列についてこの場で解説してしまうと非常に長くなってしまうので、今回は使い方とざっくりとした概要の解説だけに留めます。
ちなみに、今回はオブジェクトを回転させるための回転行列を解説しますが、平行移動や拡大縮小に関しては、レイマーチングでは特に行列を使わずにやってしまったほうが簡単なケースが多いです。また、行列は解説しているサイトが非常に豊富です。気になる方は、ぜひ自分で調べてみてください。それでは、該当のコードを抜粋してみます。
行列による回転を行う関数
vec3 rotate(vec3 p, float angle, vec3 axis){
vec3 a = normalize(axis);
float s = sin(angle);
float c = cos(angle);
float r = 1.0 - c;
mat3 m = mat3(
a.x * a.x * r + c,
a.y * a.x * r + a.z * s,
a.z * a.x * r - a.y * s,
a.x * a.y * r - a.z * s,
a.y * a.y * r + c,
a.z * a.y * r + a.x * s,
a.x * a.z * r + a.y * s,
a.y * a.z * r - a.x * s,
a.z * a.z * r + c
);
return m * p;
}
今回用意したユーザー定義の関数 rotate
は、引数をみっつ取ります。
第一引数は、レイの先端の座標として distance function に渡されたものをそのまま与えれば大丈夫です。第二引数の angle
は、その名の通りアングルを表すものでこれは要は回転させる角度になります。
第三引数の axis
は、どの軸に対してどの程度の回転を加えるかを表します。たとえば、X 軸を 100 %として、Y 軸は 50 %、Z 軸は回転させない、という指定をする場合は、 axis
には vec3(1.0, 0.5, 0.0)
を指定すればいいですね。
肝心の関数の中身を見てみると、まずは axis
を正規化しています。このことからわかるように、 axis
に与えるベクトルは事前に正規化しておく必要はありません。
続いて第二引数の angle
からサインとコサインを算出していますね。このことから、 angle
にはラジアンで角度を渡す必要があることがわかります。
そこから先は回転行列を生成するための計算です。 mat3
型の変数に値を設定しているので、これが 3x3 の行列であることがわかりますね。 mat3
は vec3
と直接掛け合わせても GLSL が勝手に計算してくれるので、これを戻り値として返します。
つまり、この関数は指定された分の回転を与え、その結果の三次元ベクトルを返してくれるというわけです。もっと簡潔に言うなら、座標を指定された分だけ回転してくれるわけですね。
シェーダの全体像
それではシェーダ全体のコードを見てみます。徐々に、サンプルのシェーダのソース量が増えてきていますが、ポイントを絞って見ていけばそれほど変更されている箇所も多くありません。臆せず見ていきましょう。
サンプルのフラグメントシェーダコード
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.577, 0.577, 0.577);
// rotate
vec3 rotate(vec3 p, float angle, vec3 axis){
vec3 a = normalize(axis);
float s = sin(angle);
float c = cos(angle);
float r = 1.0 - c;
mat3 m = mat3(
a.x * a.x * r + c,
a.y * a.x * r + a.z * s,
a.z * a.x * r - a.y * s,
a.x * a.y * r - a.z * s,
a.y * a.y * r + c,
a.z * a.y * r + a.x * s,
a.x * a.z * r + a.y * s,
a.y * a.z * r - a.x * s,
a.z * a.z * r + c
);
return m * p;
}
// smoothing min
float smoothMin(float d1, float d2, float k){
float h = exp(-k * d1) + exp(-k * d2);
return -log(h) / k;
}
// torus
float distFuncTorus(vec3 p, vec2 r){
vec2 d = vec2(length(p.xy) - r.x, p.z);
return length(d) - r.y;
}
// box
float distFuncBox(vec3 p){
return length(max(abs(p) - vec3(2.0, 0.1, 0.5), 0.0)) - 0.1;
}
// cylinder
float distFuncCylinder(vec3 p, vec2 r){
vec2 d = abs(vec2(length(p.xy), p.z)) - r;
return min(max(d.x, d.y), 0.0) + length(max(d, 0.0)) - 0.1;
}
// distance function
float distFunc(vec3 p){
vec3 q = rotate(p, radians(time * 10.0), vec3(1.0, 0.5, 0.0));
float d1 = distFuncTorus(q, vec2(1.5, 0.25));
float d2 = distFuncBox(q);
float d3 = distFuncCylinder(q, vec2(0.75, 0.25));
return smoothMin(smoothMin(d1, d2, 16.0), d3, 16.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);
}
ほんとにコードの量が多い! そろそろ抜粋コードのみの掲載にしていかないときついかもしれません(笑)
前回はオブジェクトを斜め上から見下ろすような視線になるようにカメラを配置していましたが、今回はオブジェクトのほうが回転しますので、Z 軸上に戻しました。
今回のサンプルでは、先述のシリンダー用の distance function や、行列処理のための関数が追加されていますが、それ以外の部分では distance function を個別に呼び出している関数 distFunc
の部分が一番修正個所が多いですね。
distFunc 関数
float distFunc(vec3 p){
vec3 q = rotate(p, radians(time * 10.0), vec3(1.0, 0.5, 0.0));
float d1 = distFuncTorus(q, vec2(1.5, 0.25));
float d2 = distFuncBox(q);
float d3 = distFuncCylinder(q, vec2(0.75, 0.25));
return smoothMin(smoothMin(d1, d2, 16.0), d3, 16.0);
}
座標を行列により変換する処理は、できるだけコールされる回数が少ないほうがいいですね。ですから、各 distance function の中で個別に座標を変換するよりも、まずここで先行して座標変換を行ってしまいます。
回転する度合いを表す angle
には、先ほども書いたようにラジアンで角度を与える必要がありますので、ここではビルトイン関数の radians
を使って、経過時間から角度(今回の場合 1 秒あたり 10 度)が定まるようにしてあります。
みっつの float
型の変数、 d1
、 d2
、 d3
には、今回のサンプルに登場する三種類のオブジェクトのそれぞれの distance function の結果を一度代入します。
紛らわしいのはその次の行。値を返している最後の行ですね。
該当箇所の抜粋
return smoothMin(smoothMin(d1, d2, 16.0), d3, 16.0);
これを見ると、前回登場したスムーズな補間を行う smoothMin
を組み合わせて使っているのがわかりますね。レイマーチングは、結局のところ最も 0 に近い値さえ返せばレンダリングには支障はありません。このように値を比較しながら正しく戻してあげれば、それぞれの接点部分は綺麗に補間されて描かれます。
distance function の結果の返し方
先ほどの例では、戻り値を返すために smoothMin
を二回、重ねるような感じで利用していますね。ですが、実はここ、 smoothMin
を一回しか使わずに同様の結果を返すことができます。
どういうことだか、わかるでしょうか。
今回のサンプルでは、中心部分に描かれる円柱、トーラス、板状のボックスという三種類のオブジェクトがあります。ボックスは、円柱とトーラス、両方と接触していますね。しかし円柱とトーラスは、接触している部分がありません。
接触する可能性がまったくない部分であれば、わざわざ smoothMin
で補間処理してやる必要ありませんので、もっと負荷の少ないビルトイン関数などを使ったほうが高速になります。つまり、まず最初に円柱とトーラスのそれぞれの distance function の結果を min
で比較してやり、それからボックスの distance function の結果と補間処理してやればいいわけです。
もちろん、オブジェクトが拡大縮小したり個別に移動したりする場合はこの限りではありませんので、臨機応変に状況に応じて最適化をするといいでしょう。
まとめ
さて、GLSL だけで行列による座標の変換を行ってみましたが、いかがでしたでしょうか。
行列はどうしても難しいイメージが付きまといますので、どうしても中身が理解できないなら無理はせずに、まずは動かして使い方を覚えることから始めればいいと思います。
今回はその他にも、円柱、つまりシリンダー型のオブジェクトの distance function についても解説しましたが、もし事前にトーラスの distance function について理解できていたのであれば、それほど難しくなかったと思います。この辺も、なんというか経験値が溜まってくると急に理解できたりイメージできたりするもののような気がします。
ともあれ、行列を用いてオブジェクトを自由に回転させられるようになったので、かなりいろいろな描画が行えるようになったと思います。工夫次第では、マウスカーソルに連動してオブジェクトを動かすなんてこともできるでしょう。そのための基本は、既に解説済みです。あとは創意工夫あるのみですね。
実際に動作するサンプルはいつものように以下のリンクから。