WebGL
GLSL
3D
Shader
shadertoy

webgl-waterのコースティクス解説


はじめに

最近バズっていたwebgl-waterのコースティクスをshadertoyで実装しました。せっかくなので簡単に解説したいと思います。

https://www.shadertoy.com/view/MttBRS

bloggif_5bcc28fb8c4ec.gif

https://www.shadertoy.com/view/MldfDn

water.gif

レイマーチングで絵が出せるぐらいの予備知識があると、ソースを理解しつつ読み進めることが出来ると思います。

レイマーチングについては@gam0022さんのスライドが詳細で分かりやすく解説されているのでおススメです。

シェーダだけで世界を創る!three.jsによるレイマーチング

解説に入る前に、見慣れない方もいると思うので webgl-waterコースティクス についての説明を先にします。


webgl-waterとは

2011年に登場した、ブラウザで動作する水面のリアルタイムシミュレーションです。

http://madebyevan.com/webgl-water/

私が初めて目にしたのは、2012か3年頃だったので時々バズっているみたいです。(2015年頃にもバズってた気がする)

インタラクティブに操作できる水面やソフトシャドウ等、素晴らしい品質の映像を見ることができますが、このデモを見た時、特に目を引かれるのはおそらく水底に映るコースティクスだと思います。

当時自由に動かせる水面下にコースティクスがリアルタイムで表示されているのを見て、理解不能な謎技術に驚いたのを今でも覚えています。


コースティクスとは

CGWORLD Entry.jp CG入門者のための情報サイト様から引用します。


液体の入ったグラス、反射率の高い金属などに入射した光は、複雑な反射・屈折を繰り返し、周囲の物体の表面に独特の光の模様や筋などをつくる。この集光現象をコースティクスとよぶ。これを3DCGで表現する場合には、フォトンマップ法とよばれるレンダリングアルゴリズムを用いる。

(出展:CGWORLD Entry.jp CG入門者のための情報サイト)


webgl-waterにおけるコースティクスは水面を屈折した光が集まることによって現れる模様です。

オフラインレンダリングではパストレースと呼ばれる古典的な手法でその地点に光がどれほど集まっているか時間をかけて計算することができますが、webgl-waterのようにリアルタイムで動作させたい場合には現実的ではありません。

では、webgl-waterのコースティクスはどのように計算しているのか?

この記事ではコースティクスの手法とフラグメントシェーダーの実装について解説します


webgl-waterのコースティクス手法

前述したようにリアルタイムレンダリングでは何度もレイトレースして光を蓄積するといった手法は現実的ではありません。

なので、webgl-waterでは以下の手順でレイトレースを2回に抑えることでリアルタイムにコースティクスを表現しています。


  • ハイトマップなどで変形する前(平面)の水面で屈折した光線と、水底の交差点の近傍ピクセル間の面積を計算

  • 変形後の水面で屈折した光線と、水底の交差点の近傍ピクセル間の面積を計算

  • 面積の比を光の蓄積量として明るさを決定

image.png

変形後の面積が大きいと光が拡散して暗くなり、逆に小さいと光が集まって明るくなります。

単純な手法ですが、屈折の対象と投影先があまり複雑でない(平面が支配的な)場合はこれだけでコースティクスを計算できます!

なぜ平面限定なのかというのは、複雑だと一度目と二度目のレイの交差点の面積の計算が同じ平面上で行えない場合があるからなのですが、時間があれば図を用意します

製作者様が解説記事を公開しているので気になる方はこちらを参照してください。

上記の画像もこちらの記事の物です。

https://medium.com/@evanwallace/rendering-realtime-caustics-in-webgl-2a99a29a0b2c


フラグメントシェーダーで実装

手法がわかったのでフラグメントシェーダーで実装します。


近傍ピクセルの面積

フラグメントシェーダーでは他のピクセルの変数等にアクセスすることが出来ないため、GLSL初心者の方だと「フラグメントシェーダーで近傍ピクセルの面積をどうやって求めるのか」といった点に躓くかもしれません。

そこでdFdx, dFdyという偏微分関数を紹介します。

この関数を用いることで縦横ピクセルの入力値の差分を計算することが出来るため、近傍ピクセル間の面積をこのように求めることができます。


// 左右の近傍ピクセル間の距離
float len_x = length(dFdx(pos));

// 上下の近傍ピクセル間の距離
float len_y = length(dFdy(pos));

float area = len_x * len_y;


コーティクスを計算する

冒頭でも紹介しましたが、こちらが今回作成したコースティクスのフラグメントシェーダー実装です。

https://www.shadertoy.com/view/MttBRS

https://www.shadertoy.com/view/MldfDn

コースティクス以外は特に特殊なことをしていないのでコーティクスを計算している関数に解説コメントをつけたものを紹介します。

float caustics(vec3 p, vec3 lp) {

// 光源から今見ているピクセルのワールド座標へのベクトル
vec3 ray = normalize(p - lp);

// 今見ているピクセルが影なら光が届いていないとする
vec2 shadow = scene(lp, ray);
float l = distance(lp + ray * shadow.x, p);
if (l > 0.01) {
return 0.0;
}

// 変形前の水面の位置を求める
vec2 d = water(lp, ray);
vec3 waterSurface = lp + ray * d.x;

// 変形前の水面を屈折した光線と水底の交差点を求める
vec3 refractRay = refract(ray, vec3(0., 1., 0.), 1.0/1.333);
vec2 beforeHit = bottom(waterSurface, refractRay);
vec3 beforePos = waterSurface + refractRay * beforeHit.x;

// 変形後の水面の位置を求める
vec3 noisePos = waterSurface + vec3(0.,iTime * 2.0,0.);
float height = noise(noisePos);
vec3 deformedWaterSurface = waterSurface + vec3(0., height, 0.);

// 変形後の水面を屈折した光線と水底の交差点を求める
refractRay = refract(ray, waterNormal(noisePos, 0.5), 1.0/1.333);
vec2 afterHit = bottom(deformedWaterSurface, refractRay);
vec3 afterPos = deformedWaterSurface + refractRay * afterHit.x;

// 面積の比率を明るさにする
float beforeArea = length(dFdx(beforePos)) * length(dFdy(beforePos));
float afterArea = length(dFdx(afterPos)) * length(dFdy(afterPos));
return max(beforeArea / afterArea, .001);
}

この関数に水底のピクセル位置と光源の位置を与えると冒頭のような絵が描画できます。

image.png

image.png


さいごに

今回の実装では水面の変形に正弦波やパーリンノイズをアニメーションさせたものを使用しましたが、任意のハイトマップを使用することができるので色々使いどころがあるのではないかと妄想しています。