概要
今回はこちらのブログ(Ray Marching Fog With Blue Noise - The blog at the bottom of the sea)で紹介されているShadertoyの作品(Blue Noise Fog )のコードリーディングをしてみたいと思います。
実際に動いているシーンはこんな感じ。いわゆる『ゴッドレイ』を実現しています。
タイトルはBlue Noise Fogと、ノイズについての話がメイントピックなのですが、個人的にはこの『ゴッドレイ』の実装に興味があって読み解きました。
コードリーディング
さっそくコードリーディングしていきましょう。
レイマーチングのコードを読む場合は一番下から上に向かって読んでいくと理解がしやすいです。
C言語的な部分もあって関数の宣言前に呼び出すことができないからです。
なので処理は下から上に向かって書かれていることが多いです。
フラグメントシェーダ(mainImage関数)
ということで最下部の mainImage
関数から見ていきましょう。
この関数はShadertoyが提供してくれている、フラグメントシェーダに相当するmain関数です。
void mainImage( out vec4 fragColor, in vec2 fragCoord )
{
// get the camera vectors
vec3 cameraPos, cameraFwd, cameraUp, cameraRight;
GetCameraVectors(cameraPos, cameraFwd, cameraUp, cameraRight);
// calculate the ray direction for this pixel
vec2 uv = fragCoord / iResolution.xy;
float aspectRatio = iResolution.x / iResolution.y;
int panel = 0;
vec3 rayDir;
{
panel = int(dot(floor(uv*2.0f), vec2(1.0f, 2.0f)));
vec2 screen = fract(uv*2.0f) * 2.0f - 1.0f;
screen.y /= aspectRatio;
float cameraDistance = tan(c_FOV * 0.5f * c_pi / 180.0f);
rayDir = vec3(screen, cameraDistance);
rayDir = normalize(mat3(cameraRight, cameraUp, cameraFwd) * rayDir);
}
// do rendering for this pixel
float rayHitTime;
vec3 pixelColor = GetColorForRay(cameraPos, rayDir, rayHitTime);
// apply fog
pixelColor = ApplyFog(cameraPos, rayDir, pixelColor, rayHitTime, panel, fragCoord);
// tone map the color to bring it from unbound HDR levels to SDR levels
//pixelColor = ACESFilm(pixelColor);
// convert to sRGB, then output
pixelColor = LinearToSRGB(pixelColor);
fragColor = vec4(pixelColor, 1.0f);
}
冒頭部分はカメラの姿勢の計算と Ray
の飛ばす方向を決めています。
なぜこの計算でそうなるのかは今回は本筋から外れるので割愛します。
関数部分は以下になっています。
カメラの姿勢計算(GetCameraVectors関数)
カメラの姿勢を取得する関数です。
void GetCameraVectors(out vec3 cameraPos, out vec3 cameraFwd, out vec3 cameraUp, out vec3 cameraRight)
{
vec2 mouse = iMouse.xy;
if (dot(mouse, vec2(1.0f, 1.0f)) == 0.0f)
{
mouse = c_defaultMousePos * iResolution.xy;
}
float angleX = -mouse.x * 16.0f / float(iResolution.x);
float angleY = mix(c_minCameraAngle, c_maxCameraAngle, mouse.y / float(iResolution.y));
cameraPos.x = sin(angleX) * sin(angleY) * c_cameraDistance;
cameraPos.y = -cos(angleY) * c_cameraDistance;
cameraPos.z = cos(angleX) * sin(angleY) * c_cameraDistance;
cameraPos += c_cameraAt;
cameraFwd = normalize(c_cameraAt - cameraPos);
cameraRight = normalize(cross(cameraFwd, vec3(0.0f, 1.0f, 0.0f)));
cameraUp = normalize(cross(cameraRight, cameraFwd));
}
sin
, cos
関数を使って衛星軌道上のような動きをさせていますね。
加えてマウス操作で色々な方向からシーンを見ることができるようになっています。
レイマーチングで描く(GetColorForRay関数)
名前からも分かる通りオブジェクトの色を算出するための関数です。
通常のレイマーチングを行ってオブジェクトをレンダリングしています。
この関数だけでレンダリングすると以下のような結果を得ることができます。
いわゆる普通のレイマーチングの画面ですね。
レイマーチングについては以前記事を書いているのでそちらを参考にしてください。
ではひとつずつ見ていきましょう。まずは関数全体を俯瞰します。
vec3 GetColorForRay(in vec3 rayPos, in vec3 rayDir, out float hitDistance)
{
// trace primary ray
SRayHitInfo hitInfo = RayVsScene(rayPos, rayDir);
// set the hitDistance out parameter
hitDistance = hitInfo.dist;
if (hitInfo.dist == c_rayMaxDist)
return texture(iChannel0, rayDir).rgb;
// trace shadow ray
vec3 hitPos = rayPos + rayDir * hitInfo.dist;
hitPos += hitInfo.normal * c_hitNormalNudge;
SRayHitInfo shadowHitInfo = RayVsScene(hitPos, c_lightDir);
float shadowTerm = (shadowHitInfo.dist == c_rayMaxDist) ? 1.0f : 0.0f;
// do diffuse lighting
float dp = clamp(dot(hitInfo.normal, c_lightDir), 0.0f, 1.0f);
return c_lightAmbient * hitInfo.diffuse + dp * hitInfo.diffuse * c_lightColor * shadowTerm;
}
この関数からさらに RayVsScene
という関数が呼び出されています。
これはシーン内にあるオブジェクトを見つけてくる関数です。(要はレイマーチングさせるための関数)
戻り値は SRayHitInfo
構造体になっていて、この構造体にはレイがヒットしたときの情報が格納されています。
関数の定義は以下のようになっています。
struct SRayHitInfo
{
float dist;
vec3 normal;
vec3 diffuse;
};
定義されているフィールドは3つで、距離、法線、拡散を返すようになっていますね。
距離はレイマーチングした際の距離、法線はヒットしたオブジェクトの法線、そして拡散はそのオブジェクトの色です。
RayVsScene
関数の中身については後ほど見るとして、まずは全体的になにをしているのか見ていきましょう。
背景のレンダリング
if (hitInfo.dist == c_rayMaxDist)
return texture(iChannel0, rayDir).rgb;
冒頭の RayVsScene
の戻り値を元に if
文によって分岐しています。
これは単に、レイがどこにもヒットしていない場合 1 にCubeMapの値をサンプリングして終了しています。
要は背景のレンダリングですね。
影のトレース
続けて見ていきましょう。
コメントにもあるように次の処理は影のトレースです。
さらに RayVsScene
が呼び出されているのが分かります。
vec3 hitPos = rayPos + rayDir * hitInfo.dist;
hitPos += hitInfo.normal * c_hitNormalNudge;
SRayHitInfo shadowHitInfo = RayVsScene(hitPos, c_lightDir);
float shadowTerm = (shadowHitInfo.dist == c_rayMaxDist) ? 1.0f : 0.0f;
1行目の処理は前段の計算で得られた結果を元にレイが現在あたっている位置を求める処理です。
rayPos
はカメラ位置なので、レイの進行方向 x ヒット距離を足すことで位置を求めているわけです。
vec3 hitPos = rayPos + rayDir * hitInfo.dist;
次に行っているのはヒットしたオブジェクトの法線方向に少しだけ位置をずらしています。
hitPos += hitInfo.normal * c_hitNormalNudge;
なぜこれをしているかというと、次の行でライト方向に再びレイマーチングを開始しますがオブジェクトの表面にレイ位置があるままだとレイマーチングの性質上レイが一切進まなくなってしまうため、それを防ぐ目的で少しだけオフセットさせているというわけです。
(ちなみに c_hitNormalNudge
の値は定数で 0.1
に設定されています)
なぜレイの位置をオフセットしないとレイの位置が更新されないのかについてはここでは解説しません。
以前自分が書いたレイマーチング入門の記事などレイマーチングの仕組みを理解すると分かるかと思います。
影の計算
レイの出発点が決まったのでこれを元に影の計算をしていきます。
SRayHitInfo shadowHitInfo = RayVsScene(hitPos, c_lightDir);
float shadowTerm = (shadowHitInfo.dist == c_rayMaxDist) ? 1.0f : 0.0f;
再び RayVsScene
が呼び出されています。
今度は引数が異なっていますね。最初の呼び出しは RayVsScene(rayPos, rayDir);
でした。
今回の呼び出しは RayVsScene(hitPos, c_lightDir);
となっています。
RayVsScene
関数が行っているのは引数に渡された位置から指定方向へレイマーチングさせることです。
なぜこれで影の計算ができるのかは今回の主旨からはずれるので少しだけ解説するにとどめます。
もし詳細を知りたい方は以下の記事を読むとより理解が深まるでしょう。
なぜこれで影の計算が行えるのかというと。
影というのはライトに遮られた箇所にできるという視点に立つと分かります。
つまり、まず最初のレイマーチングでオブジェクトの位置を特定したあと、そこからライト方向に向かってレイを飛ばすことでオブジェクトとライトとの間になにか遮蔽するものがあるかどうかを判定することができるわけです。
上記サイトから画像を引用させていただくと以下のイメージです。
そして最後に、判定した値が c_rayMaxDist
と同じかどうか、言い換えるとレイがどのオブジェクトにも当たらなかったかをチェックし、当たっていなければ 1
を、当たっていれば 0
を設定します。
この shadowTerm
の値が使われている箇所を見るとどういう処理かが分かると思います。
return c_lightAmbient * hitInfo.diffuse + dp * hitInfo.diffuse * c_lightColor * shadowTerm;
色の計算の最後に掛けられていますね。これが 1
or 0
なのですから、つまりは影なら色をレンダリングしない(=黒)ということになります。
これで影がレンダリングされます。
ゴッドレイのレンダリング
今回のコードリーディングの本丸、ゴッドレイの実装部分を見ていきましょう。
ゴッドレイをレンダリングしている関数は ApplyFog
です。
まずはさっと実装を見てみましょう。
// ray march from the camera to the depth of what the ray hit to do some simple scattering
vec3 ApplyFog(in vec3 rayPos, in vec3 rayDir, in vec3 pixelColor, in float rayHitTime, in int panel, in vec2 pixelPos)
{
// Offset the start of the ray between 0 and 1 ray marching steps.
// This turns banding into noise.
int frame = 0;
#if ANIMATE_NOISE
frame = iFrame % 64;
#endif
float startRayOffset = 0.0f;
if (panel == 0)
{
startRayOffset = 0.5f;
}
else if (panel == 1)
{
// white noise
startRayOffset = hash13(vec3(pixelPos, float(frame)));
}
else if (panel == 2)
{
// blue noise
startRayOffset = texture(iChannel1, pixelPos / 1024.0f).r;
startRayOffset = fract(startRayOffset + float(frame) * c_goldenRatioConjugate);
}
else if (panel == 3)
{
// interleaved gradient noise
startRayOffset = InterleavedGradientNoise(pixelPos, frame);
}
// calculate how much of the ray is in direct light by taking a fixed number of steps down the ray
// and calculating the percent.
// Note: in a rasterizer, you'd replace the RayVsScene raytracing with a shadow map lookup!
float fogLitPercent = 0.0f;
for (int i = 0; i < c_numRayMarchSteps; ++i)
{
vec3 testPos = rayPos + rayDir * rayHitTime * ((float(i)+startRayOffset) / float(c_numRayMarchSteps));
SRayHitInfo shadowHitInfo = RayVsScene(testPos, c_lightDir);
fogLitPercent = mix(fogLitPercent, (shadowHitInfo.dist == c_rayMaxDist) ? 1.0f : 0.0f, 1.0f / float(i+1));
}
vec3 fogColor = mix(c_fogColorUnlit, c_fogColorLit, fogLitPercent);
float absorb = exp(-rayHitTime * c_fogDensity);
return mix(fogColor, pixelColor, absorb);
}
ぱっと見、長めに見えますが実は最初の分岐は4分割されたスクリーンそれぞれの設定を行っているだけです。
なので着目すべき部分は以下の箇所になります。
// calculate how much of the ray is in direct light by taking a fixed number of steps down the ray
// and calculating the percent.
// Note: in a rasterizer, you'd replace the RayVsScene raytracing with a shadow map lookup!
float fogLitPercent = 0.0f;
for (int i = 0; i < c_numRayMarchSteps; ++i)
{
vec3 testPos = rayPos + rayDir * rayHitTime * ((float(i)+startRayOffset) / float(c_numRayMarchSteps));
SRayHitInfo shadowHitInfo = RayVsScene(testPos, c_lightDir);
fogLitPercent = mix(fogLitPercent, (shadowHitInfo.dist == c_rayMaxDist) ? 1.0f : 0.0f, 1.0f / float(i+1));
}
vec3 fogColor = mix(c_fogColorUnlit, c_fogColorLit, fogLitPercent);
float absorb = exp(-rayHitTime * c_fogDensity);
return mix(fogColor, pixelColor, absorb);
順番に見ていきましょう。
ちなみにコメントでは以下のように説明されていますね。
calculate how much of the ray is in direct light by taking a fixed number of steps down the ray
and calculating the percent.
Note: in a rasterizer, you'd replace the RayVsScene raytracing with a shadow map lookup![訳] 固定長のステップで進めたレイがどれくらいの光(direct light)の中にいるかを計算し、そしてパーセンテージを計算します。
メモ:ラスタライザでは、RayVsSceneのレイトレーシングをシャドウマップのルックアップに置き換えることができます。
コードを詳細に読んでいく前に、ここがなにを説明しているかを想像しておきましょう。
以前書いた記事に載せた画像を引用するとおそらく以下のようなことをやるのだと思います。
上図は雲のライティングを行う際に計算する仕組みを図示したものです。
この図が言っているのは、カメラからレイを飛ばす際にレイを決まった長さ分だけ進めてそのときの雲の密度を計算するというものです。
要はレイが進む各点を観測点として、その点の部分の値を累積させることで陰影を出すわけです。
そしておそらくゴッドレイでも同様に、固定長でレイを進めてそのレイが光の中を進んでいるのかそれとも影の部分を進んでいるのかを累積することで陰影を作っているのだと思います。
ApplyFog関数を読み解く
では実際にコードを読んでいきましょう。
まずは以下の部分から。
float fogLitPercent = 0.0f;
for (int i = 0; i < c_numRayMarchSteps; ++i)
{
vec3 testPos = rayPos + rayDir * rayHitTime * ((float(i)+startRayOffset) / float(c_numRayMarchSteps));
SRayHitInfo shadowHitInfo = RayVsScene(testPos, c_lightDir);
fogLitPercent = mix(fogLitPercent, (shadowHitInfo.dist == c_rayMaxDist) ? 1.0f : 0.0f, 1.0f / float(i+1));
}
fogLitPercent
はコメントで解説されているパーセンテージを表す変数でしょう。
続く for
ループで実際の計算を行っています。
c_numRayMarchSteps
は定数で 16
が設定されています。
つまりゴッドレイ用のレイマーチングを16回繰り返して累積させているというわけですね。
そのまま中の処理を見ていきます。
rayHitTime
は「タイム」と名前がついていますが実際には距離が格納されています。
GetColorForRay
関数の out float hitDistance
変数がその正体です。
実装部分を見てみると以下のようになっています。
vec3 GetColorForRay(in vec3 rayPos, in vec3 rayDir, out float hitDistance)
{
// trace primary ray
SRayHitInfo hitInfo = RayVsScene(rayPos, rayDir);
// set the hitDistance out parameter
hitDistance = hitInfo.dist;
// ... 後略
}
通常のレイマーチングをしてレイがヒットしたオブジェクトまでの距離が入っているということですね。
それを念頭にコードを見てみましょう。
vec3 testPos = rayPos + rayDir * rayHitTime * ((float(i)+startRayOffset) / float(c_numRayMarchSteps));
startRayOffset
はノイズによるゆらぎを表しているので、いったんこれをなくします。
さらに読みやすくするために float
へのキャストも外してみると、
vec3 testPos = rayPos + rayDir * rayHitTime * (i / c_numRayMarchSteps);
i / c_numRayMarchSteps
はループが進むにつれてレイが伸びていくことを示しています。
つまり日本語で説明すると、
オブジェクトがある場所までの距離をループ回数で割って、ループするたびにレイの位置を伸ばしていく
ということです。
図にすると以下のイメージです。
SRayHitInfo shadowHitInfo = RayVsScene(testPos, c_lightDir);
再び RayVsScene
関数が使われています。そして今回の場合の引数は、先ほど求めたレイの位置とライト方向へのベクトルを渡して計算を行っています。
戻り値の意味は、計算時点でのレイの位置からライト方向に遮蔽物があるかないかを求めていることになりますね。
そしてその結果を使ってパーセンテージを求めます。
計算式は以下。
fogLitPercent = mix(fogLitPercent, (shadowHitInfo.dist == c_rayMaxDist) ? 1.0f : 0.0f, 1.0f / float(i+1));
三項演算子があるので分解してみましょう。
float inShadow = (shadowHitInfo.dist == c_rayMaxDist) ? 1.0f : 0.0f;
float progress = 1.0f / float(i + 1);
fogLitPercent = mix(fogLitPercent, inShadow, progress);
まず、計算時点のレイの位置からライト方向へのレイマーチングの結果がなにも遮蔽されていない場合、 inShadow
変数は 1.0
になり、なにかに遮蔽されている場合は 0.0
になります。
求めたいのはなにかに遮蔽されているところをレイが進んでいるかなのでそのために遮蔽を見ているというわけですね。
そして progress
は i
の値が増えるにつれてどんどんその値が小さくなっていきます。
グラフにすると以下のような曲線になります。
ここから分かるのはループが進むほど inShadow
で計算された値の影響度が下がっていくということです。
mix
関数は第一引数と第二引数を第三引数の割合に応じて線形補間するものです。
つまり、ループが進むほど値が 0
に近づくため inShadow
の値の影響度が小さくなっていく、というわけですね。
求めた fogLitPercent
を使ってゴッドレイを表現する
最後に、今求めた fogLitPercent
を利用して最終的な色を決定します。(つまりゴッドレイの表現)
計算式は以下です。
vec3 fogColor = mix(c_fogColorUnlit, c_fogColorLit, fogLitPercent);
float absorb = exp(-rayHitTime * c_fogDensity);
return mix(fogColor, pixelColor, absorb);
fogColor
は変数名の通りフォグの色ですね。
定数として定義されているUnlitなフォグの色とLitなフォグの色を fogLitPercent
の値によってブレンドしています。
※ ちなみにそれぞれの色は完全な黒と白が定義されています。
const vec3 c_fogColorLit = vec3(1.0f, 1.0f, 1.0f);
const vec3 c_fogColorUnlit = vec3(0.0f, 0.0f, 0.0f);
つまり、レイが通過していく直線がどのオブジェクトにも遮られていない場合は c_fogColorLit
が100%採用される=白くなるというわけですね。
光の吸収を計算
次の計算は(変数名から察するに)光の吸収についての計算でしょう。
// const float c_fogDensity = 0.002f;
float absorb = exp(-rayHitTime * c_fogDensity);
exp(-rayHitTime * c_fogDensity);
の計算はレイの距離が大きくなればなるほど 0
に近づいていく計算です。
つまり言い換えると、なにかしらのオブジェクトにヒットしている場合は rayHitTime
の値は小さい値になっているはずなので pixelColor
の色が強く影響し、そうでない場合は fogColor
の色が強く影響する、というわけです。
試しにゴッドレイを適用しないときの絵とゴッドレイだけの絵を比べてみると分かりやすいかと思います。
ゴッドレイを適用しない絵( absorb
が 1.0
の絵)
色の決定
以上でロジック部分は終了です。
最後に色を返しているところを見て終わりましょう。
pixelColor = LinearToSRGB(pixelColor);
fragColor = vec4(pixelColor, 1.0f);
LinearToSRGB
関数は名前から察するに色をリニアに変換しているのでしょう。
いわゆるガンマ補正をなくす処理ですね。
ガンマ補正については以下の記事が詳しいです。
ただガンマ補正は 1 / 2.2
なので若干値が異なり、さらに細かい数値で補正しているのが気になりますね。
これについては後日調べてみたいと思います。
ここではたんに色味を調整していると考えておけば大丈夫だと思います。
vec3 LinearToSRGB(vec3 rgb)
{
rgb = clamp(rgb, 0.0f, 1.0f);
return mix(
pow(rgb * 1.055f, vec3(1.f / 2.4f)) - 0.055f,
rgb * 12.92f,
LessThan(rgb, 0.0031308f)
);
}
そして変換を行った色を fragColor
に設定して最終的な色を決定しています。
イチから実装してみる
今回コードリーディングした内容を踏まえて自分でイチから実装してみました。
結果は以下です。
- Godray fog
いくつかパラメータを調整しましたが概ねうまく動きました。
やはり光の表現が加わるとぐっといい絵になりますね。
まとめ
読み解いてみると意外とシンプルな実装でした。
今回これを知ったのは @VoxelKei さんがUnityでボリュームライトを実装していて質問したところ、この記事を教えてもらったのがきっかけでした。
@VoxelKei さんのボリュームライトはBOOTHで販売されているのでそちらを購入することで実装を見ることができます。
基本的には今回読み解いたものをベースとしつつ、Unityはレイマーチングではないので代わりに深度を求める方法を採用してい実装されていました。
興味がある方は購入してコードを読んでみると学びがあると思います。
ちなみにこんな感じのものです↓
誰でもどこでも神になれるライト「VoxkeVolumetricLight」をBOOTHで公開しました。スクリプトが使えないVRChatでもボリューメトリックなライトが使えるパッケージです。詳しい使い方説明はFANBOXに投稿しました。デモワールドもあります。買ってね!!!#VRChat https://t.co/ukfGwtWB9H pic.twitter.com/3HesjvuEzc
— VoxelKei (@VoxelKei) May 25, 2020
-
ヒット位置の距離が
c_rayMaxDist
ならどのオブジェクトにもヒットせずにレイが進んだということ。 ↩