Edited at

Cloud Raymachingの翻訳記事

この記事は以下の英語記事を、本人に許諾を得て翻訳、投稿したものとなります。

なお、英語力はそこまで高くなく、多分にGoogle翻訳も混ぜていることにご留意ください。


いくつかのこのブログの投稿は、ボリュメトリックなレイマーチングシェーダを作る上で必要なことを構築してきました。

簡単にそれらを振り返りましょう。

ボリュームテクスチャの生成:この投稿ではパラパラ漫画のようなフレームを2次元配列としてテクスチャスライスによる保存での疑似ボリュームテクスチャ生成方法を見てきました。これと同様のメソッドを使ってテクスチャのエンコードおよびデコードを行います。http://shaderbits.com/blog/authoring-pseudo-volume-textures

レイマーチングハイトマップ:この投稿では2D表面によるボリュメトリックシャドウの描画メソッドを説明しました。この平面自体は視差などを用いていないただの2D表面です。すべてのボリュメトリックエフェクトのために、密度を使ったシャドウトレースを実行します。http://shaderbits.com/blog/ray-marched-heightmaps

オブジェクトシャドウごとのカスタム:この投稿はオブジェクトごとのシャドウを、ライトのパースペクティブからのデプスマップを使ってどのように生成するかを示します。環境のシャドウイングオプションを追加するために同様のテクニックを適用します。http://shaderbits.com/blog/custom-per-object-shadowmaps-using-blueprints

これは私たちがなにを達成したいかです。

最終結果


ボリューム レイマーチング

ボリュメトリックレンダリングの背後にある基本コンセプトは、ライトのレイがどのようにボリュームを通過するかを評価することです。

これは一般的に、ボリュームと交差した各ピクセルの透明度とカラーを返すことを意味します。

もし、ボリュームが解析的な関数である場合、ダイレクトにその結果を計算するかもしれませんが、もしボリュームがテクスチャに保存されたものであれば、ボリュームを複数のステップによって各ステップごとにテクスチャから探す必要があります。

これは、ふたつのパーツに分解することができます。


  • 1) 不透明度(ライトの吸収)

  • 2) カラー(照明、散乱)


不透明度サンプリング

ボリュームのための不透明度の生成するには、各可視点における密度または暑さを知る必要があります。

もしボリュームが固定の密度と色を持っていることを想定されているならば、必要なすべてのことは、各レイが不透明の遮蔽物にぶつかる前までの長さの合計です。

シンプルなテクスチャのないフォグは、標準のリマップ関数から得られるただのシーンの深度です。(D3DFOG_EXP)

この関数は以下のように定義されます。

F = \frac{1}{e^{(t * d)}}

tはメディアを通過した距離、dはメディアの密度です。

これは、長い間ゲームで計算されてきた安価なunlitフォグです。

これはパーティクルボリュームを通過した透過率によって以下のように定義するBeer-Lambertの法則に由来します。

Transmittance = e^{-t * d}

これらは似たように見えるでしょう。なぜなら、これらはまさに同じことだからです。

x^{-y}は、\frac{1}{x^y}と同じです。つまり、指数フォグ関数は本当にただBeer-Lambertの法則を適用したものに過ぎません。

これら関数がどのようにボリュメトリックなものに適用されるかを理解するには、Drebinによって書かれた古い論文の方程式を指摘することができます。

それは、それを通過するときにどのくらいの光が光線方向にボクセルから出るかを説明します。

それは、ボクセルごとに固有の色を持つボリュームの正確な色を返すように設計されています。

C_{out}(v) = C_{in}(v) * (1 - Opacity(x)) + Color(x) * Opacity(x)

C_{in}(v)はボクセルを通過する前の色、C_{out}(v)はそれを通過したあとの色です。

これはボクセルごとのボリュームを通過した光の状態で、ライトの色は現在のボクセルの不透明度の逆数を掛け、吸収率をシミュレートします。また、現在のボクセルの色は現在のボクセルの不透明度を掛け、散乱のシミュレートとして加算されます。

このコードは、ボリュームを後ろから前へトレースする場合に限りそのまま機能します。

もし、透過率変数を1に初期化しトレースする場合、ボリュームはどちらの方向にもトレースすることができます。

透過率は不透明度の逆数として考えることができます。

これがExpまたはe^x関数が機能するところです。

銀行口座の利子の問題と同様に、より頻繁に口座に利子を適用すると、より多くのお金を稼ぐことができますが、それはある時点までに限られます。

その点は e によって定義されます。

ボリュームの密度を積分した結果を比較する際に同様の効果があります。

より多くのステップが実行されるほど、最終結果はExp関数またはeによって定義される解に収束するようになります。

これがBeer-Lambertの法則とD3DFOG_EXP関数の由来です。

これまでに説明した数学によって、カスタムのボリュームレンダラーを構築する方法についていくつかのヒントが得られます。

ボリュームの各ポイントの厚さを知計算する必要があることは分かっています。

厚さの値は密度の指数関数によって利用され、ボリュームがどれくらい光をブロックするかを推測します。

ボリュームの密度をサンプリングするために、ボリュームを通過する各光線に沿っていくつかのステップが実行され、ボリュームテクスチャの値が各点で読み取られます。

この例は、球体の仮想ボリュームテクスチャを示しています。

カメラの光線は、メディア内を移動した距離を測定するために定期的にボリュームをサンプリングした結果を示しています。

もし、光線がステップ中にメディアの中にある場合、ステップ長が累積変数に加算されます。

もし光線がステップ中にメディアの外側にある場合、ステップは累積されません。

この最後に、各ピクセルについて、ボリュームテクスチャ内でメディアの内側にある間にカメラの光線がどれだけ移動したかを示す値があります。

なぜなら、距離は各ポイントにおいて不透明度と乗算されるため、返される最終距離は線密度を表します。

その距離は、上の例の黄色いドット間のラインとして表されます。

上の例のように、少ないステップ数が使用されると、距離が実際のコンテンツとあまり一致しなくなり、スライスアーティファクトが目に見えるようになります。

この種のアーティファクトと解決策は、あとでさらに詳細に説明します。

この時点で、終了までの線形の値と線形の距離を累積させています。

このボリュメトリックな見た目を作るために、最終的な値を写像する指数関数を利用します。

標準のDirect3Dの指数関数フォグ D3DFOG_EXP はこれに適しています。


不透明度だけのレイマーチの例

カスタムノードですべてのレイマーチングのコードを実行することは可能ですが、複数のカスタムノードを必要とする入れ子関数呼び出しが必要です。

カスタムノードはトランスレータによって自動的に名前がつけられ、これはコンパイラが追加するオーダーを想定して呼び出す必要があることを意味します。

コンパイラは、新しいものを追加するか、またはさまざまなマテリアルピンの間でそれらがフックアップされる方法を変更するだけで、関数の名前変更を開始することができます。

この部分をもう少し簡単にするために、疑似ボリュームテクスチャ関数をcommon.usfファイルに追加しました。

'Engine\Shaders'にあるcommon.usfファイルをダウンロードして上書きするだけです。

エディタ上ですぐに実行することができます。

これは基本的に、たんに前の投稿の疑似ボリュームテクスチャを繰り返すだけです。

この関数はレイマーチングのコードをとても簡単にし、UE4の将来のバージョンでサポートが追加されたら3Dテクスチャサンプルを置き換えることができます。

もし、以下のバージョンを持っている場合、これらをダウンロードし最後の2つの関数を今のバージョンにたんにコピーします。

4.14.1バージョンがリリースされるまでは、4.14.2以降は4.13.2をオススメします。

最後にそれに入ります。


レイマーチングコード

float numFrames = XYFrames * XYFrames;

float accumdist = 0;
float3 localcamvec = normalize(mul(Parameters.CameraVector, Primitive.WorldToLocal));
float StepSize = 1 / MaxSteps;

for (int i = 0; i < MaxSteps; i++)
{
float cursample = PseudoVolumeTexture(Tex, TexSampler, saturate(CurPos), XYFrames, numFrames).r;
accumdist += cursample * StepSize;
CurPos += -localcamvec * StepSize;
}

return accumdist;

このシンプルなコードは、テクスチャ空間で0-1の距離に渡って指定されたボリュームテクスチャを通るように光線を進め、通過した微粒子の線密度を返します。

それは決して完全で欠けている重要な詳細ではありません。

のちほど、いくつかの点をコードに追加し、詳細のいくつかはマテリアルノードの形で提供されます。

これにより、使用するステップ数とフレームレイアウトを制御できます。

この単純化された例ではBoundingBoxBased_0-1_UVWノードが使われています。これは、ローカルの0-1の開始位置を簡単に取得する方法だからです。

これは、球体またはボックスのメッシュと一緒に機能しますが、最後はこれを使用しません。理由はのちほどすぐに出てきます。

それを64ステップでStaticMesh'/Engine/EditorMeshes/EditorCube.EditorCube'に配置した場合、これは次のようになります。

ランダムなボリュメトリックパフボール。

しかし、まだ興奮しすぎないようにしましょう。

上の64ステップは、結果はとてもなめらかに見えます。

32ステップでは、奇妙なスライスアーティファクトが出現します。

これらのアーティファクトは、そのマテリアルのレンダリングに使用されたボックスジオメトリを裏切っています。

これらは、ボックスの交差部分の表面からボリュームテクスチャをトレースした結果生じる、一種のモアレパターンです。

そうすることはサンプリングのパターンが箱形を継続してそれにそのパターンを与えることを引き起こします。

視点に整列した平面に開始位置をスナップさせるとアーティファクトを軽減することができます。

これは、ピクセルシェーダのみを使った、ジオメトリスライシングのアプローチによるエミュレートの例です。

これはまだ、動いているスライシングアーティファクトがありますが、それらははるかに目立たず、そして重要なボックスジオメトリを裏切りません。

追加として、テンポラルジッターを導入することで少ないステップ数でサンプリングを向上させることができます。

のちほど詳しく説明します。

これはサンプルを整列させるための追加のコードです。

// Plane Alignment

// get object scale factor
//NOTE: This assumes the volume will only be UNIFORMLY scaled. Non uniform scale would require tons of little changes.
float scale = length( TransformLocalVectorToWorld(Parameters, float3(1.00000000,0.00000000,0.00000000)).xyz);
float worldstepsize = scale * Primitive.LocalObjectBoundsMax.x*2 / MaxSteps;

float camdist = length( ResolvedView.WorldCameraOrigin - GetObjectWorldPosition(Parameters) );
float planeoffset = GetScreenPosition(Parameters).w / worldstepsize;
float actoroffset = camdist / worldstepsize;
planeoffset = frac( planeoffset - actoroffset);

float3 localcamvec = normalize( mul(Parameters.CameraVector, Primitive.WorldToLocal) );

float3 offsetvec = localcamvec * StepSize * planeoffset;

return float4(offsetvec, planeoffset * worldstepsize);

深度とアクター位置の両方が考慮されていることに注意してください。

これにより、アクターに対してスライスが安定し、カメラが近づいたり遠ざかったりしても動きません。

今、これを他のカスタムノードに配置します。

球体のような他のプリミティブをより簡単に追加できるように、コードの設定部分をコアのレイマーチングコードとは別にしておくと役立ちます。

値は直接かつ一度だけ使用されるため、これは入れ子にされたカスタムノードではありません。

他のカスタムノードから呼ばれることはありません。

次のタスクは、注意深くステップのカウントを制御することです。

もしかしたら気づいているかもしれませんが、今までのコードはレイの位置を0-1内に収めるようにされています。

それはトレーサーがボックスの端にぶかるときはいつでも、ボリュームをチェックする時間を浪費し続けます。

トレース距離は1に制限され、ボリュームのコーナーからコーナーまでの距離は1.732であるため、ボリュームのコーナーからコーナーまでの距離全体をトレースすることもありません。

内容が丸みを帯びているため、これまでの例では問題にならないだけです。

これを修正するひとつの方法は、光線がループの中でボリュームの中にあるかをチェックすることですが、それはループの中でのオーバーヘッドが増し、またできるだけシンプルに保つ必要があるためその解決策は理想的ではありません。

よりよい解決策は、適合するステップ数を事前に計算することです。

それは、ボックスや球体のようなシンプルなプリミティブを扱う場合に、厚さを決めるのにシンプルな数学が使えるようになります。

球体のほうがスクリーンのピクセルが少ないため、より高性能なのいい形状になる場合がありますが、ボックスを使用するとボリュームテクスチャのコンテンツ全体を表示でき、またボリュームを歪ませるときの柔軟性が高まります。

今のところ、ボックスを使っていきましょう。

ボックスのステップを事前計算する方法については次の通りです。

ワールド->ローカル変換により、メッシュを動かすことができます。

これは実際に上記の平面に対する整列の計算方法に関するいくつかの点を変更するので、上記のコードをこのコードにまとめただけです。

今、関数は直に、光線の入る位置と厚さを返します。

//bring vectors into local space to support object transforms

float3 localcampos = mul(float4( ResolvedView.WorldCameraOrigin,1.00000000), (Primitive.WorldToLocal)).xyz;
float3 localcamvec = -normalize( mul(Parameters.CameraVector, Primitive.WorldToLocal) );

//make camera position 0-1
localcampos = (localcampos / (Primitive.LocalObjectBoundsMax.x * 2)) + 0.5;

float3 invraydir = 1 / localcamvec;

float3 firstintersections = (0 - localcampos) * invraydir;
float3 secondintersections = (1 - localcampos) * invraydir;
float3 closest = min(firstintersections, secondintersections);
float3 furthest = max(firstintersections, secondintersections);

float t0 = max(closest.x, max(closest.y, closest.z));
float t1 = min(furthest.x, min(furthest.y, furthest.z));

float planeoffset = 1-frac( ( t0 - length(localcampos-0.5) ) * MaxSteps );

t0 += (planeoffset / MaxSteps) * PlaneAlignment;
t0 = max(0, t0);

float boxthickness = max(0, t1 - t0);
float3 entrypos = localcampos + (max(0,t0) * localcamvec);

return float4( entrypos, boxthickness );

"Ray Entry"とマーク付けられたノードは、CurPosへのインプットとしてメインのレイマーチングノードにフックします。

平面整列パラメータは、整列のオン・オフを切り替えることができます。

コードの一部では、ピボットがボックスの中心にあり、ボックスの床にはない、ボックススタティックメッシュを使用していると想定していることに注意してください。


整列

これまで、ジオメトリのローカル位置を使って外側からトレースを簡単に開始してきましたが、それでカメラがボリュームの内側に入ることはできません。

内側へ入るサポートをするには、代わりに、上記ですでに解決済みのボックスの交差位置のRay Entry Position出力を使い、またボックスジオメトリのポリゴン面を内側に向けるように反転させることができます。

これが機能するのは、ボリュームの外側のどこでレイが交差するかを知っていため、またレイがボリュームをどれくらいの距離、移動するかをすでに知っているためです。

面を反転させ、またその交差位置を使用することはカメラをボリュームの内側に行かせることができます。しかし、それはオブジェクトのソートを正しく行うことができません。

キューブの中にあるオブジェクトは、ボリュームの上に完全に描画されているように見えます。

それを解決するには、ボックスの内のレイの距離計算する際、ローカライズされたシーン深度を考慮する必要があります。

これには、セットアップ関数にいくつかのラインを追加する必要があります。

float scale = length( TransformLocalVectorToWorld(Parameters, float3(1.00000000,0.00000000,0.00000000)).xyz);

float localscenedepth = CalcSceneDepth(ScreenAlignedPosition(GetScreenPosition(Parameters)));

float3 camerafwd = mul(float3(0.00000000,0.00000000,1.00000000),ResolvedView.ViewToTranslatedWorld);
localscenedepth /= (Primitive.LocalObjectBoundsMax.x * 2 * scale);
localscenedepth /= abs( dot( camerafwd, Parameters.CameraVector ) );

//this line goes just before the line: t0 = max(0, t0);

t1 = min(t1, localscenedepth);

ここで、マテリアルのセッティングで、マテリアルがシーンとどのようにブレンドされるかをコントロールするために、Disable Depth Testをtrueに設定する必要があります。

その他の半透明オブジェクトとのソーティングは、基本的にオブジェクトごとに行われるので、あまりそれをコントロールできませんが、少なくとも不透明オブジェクトはソートすることができます。

マテリアル設定の中で、ブレンドモードを、半透明オブジェクトで起こるエッジのブレンドによるアーティファクトを回避するためにアルファ合成にも変更します。

また、マテリアルがunlitに設定されていることを確認してください。

これで、シーンの深度ルックアップをひとつ追加することによって不透明なジオメトリを使用して正確なソート生成ができるようになりました。

これは、レイマーチャーが正確な透明度を自動的に返すようになります。なぜなら、シーンの深度を超えたレイの蓄積を止めるからです。

まだひとつのアーティファクトの問題を解決しなければなりません。それは、レイマーチを常にステップサイズで止めているからです。それは、不透明なジオメトリとボリュームとの交点において階段のようなアーティファクトのように見えます。

これらのスライシングアーティファクトを修正するには、ひとつの追加ステップが必要です。

シーンの深度に、どれくらいのステップが収まるかを追跡し、残りのサイズに合うようにサイズをを設定した最後のステップをひとつ実行します。

それは最終的に、縫い目をなめらかにする深度位置で正確なサンプルを最終的に実行することを保証します。

メインのトレースをできるだけシンプルに保つために、メインループの外で追加の密度/シャドウパスを実行します。

オブジェクトの移動と視点方向の変化で、不透明オブジェクトの合成結果は正確に見えます。

ここまでのところ、かなり機能的な密度だけのレイマーチャーを見てきました。

見ての通り、コアのシェーダのレイマーチングパートはおそらく最も単純なパートです。

他のプリミティブのトレース動作を処理するためのサンプリングとソーティングの問題は少しだけトリッキーです。


ライトサンプリング

説得力のある明るさのボリュームをレンダリングするには、光の伝達の振る舞いをモデル化する必要があります。

ライトの光線がボリュームを通過すると、その量の光はボリューム中の微粒子によって吸収あるいは散乱されます。

吸収とは、光のエネルギーがボリュームによってどれくらい失われるかの量です。散乱とは、どれくらいの光が反射して出ていくかの量です。

散乱に対する吸収の比率は、微粒子の拡散輝度を決定します。

このケースでは、単純化とパフォーマンスの理由から、ひとつの種類の散乱だけを扱います。

それは基本的に、ボリュームに当たった光が等方的または拡散的に反射される量です。

インスキャッタリングとはボリューム内から跳ね返る光のことです。それは一般的にリアルタイムに行うには高価過ぎますが、それはアウトスキャッタリングの結果をぼかすことによってしっかり近似することができます。

与えられた点のアウトスキャッタリングを理解するには、光源からの光子がその点に到達したときに吸収によってどのくらいの光のエネルギーが喪失したか、そして目に向かって後方に向かってどれだけのエネルギーが失われるかを知る必要があります。

それらの値を計算するいくつかの方法がありますが、ここでは各密度サンプルから光に向かうネストされたレイマーチを実行するというブルートフォース法を主に扱います。

このメソッドは、シェーダのコストがDensity steps * Shadow steps、つまりN * Mになるためかなり高価になります。

また、実装がもっとも簡単で柔軟性があります。

上の例はひとつのカメラからのレイによる、入れ子の各密度サンプル点からのシャドウサンプルを示しています。

メディアボリューム内の密度サンプルのみシャドウサンプルを行い、レイがボリューム境界に到達するか、またはシャドウ密度が完全吸収に近い閾値を超えるとシャドウループは早く終了する可能性があります。

これらのいくつかは、劇的なN * Mの状況を少し減らすことができます。

各サンプルでは、密度が取得され、そのサンプルがどれくらいの光を散乱させることができるかを決めるのに利用されます。

それはまた、どれくらいの透過率が次のイテレーションで減少するかに影響します。

シェーダが光の方向にレイを投射し、潜在的な光エネルギーの量によってそのポイントまで到達したかどうかを確認します。

したがって、その点からカメラへ伝達される可視光は、ボリュームを通過した光子の経路の長さの合計とその点の散乱係数によってコントロールされます。

このプロセスは、Drebinの以前の式によっても説明することができます。

C_{out}(v) = C_{in}(v) * (1 - Opacity(x)) + Color(x) * Opacity(x)

しかし上の式は、カメラへのひとつの光の経路を説明するだけです。

ボリュームの不透明度を計算するだけでなく、外部散乱から光を伝播させることができるようにするには、各サンプル位置でその反復レイのサンプルを光に向かって再作成する必要があります。

さぁ、ライティング計算を説明するいくつかの基本的な関数を定義しましょう。

線形密度は、レイに沿った各点 x で、単純に不透明度 * 密度パラメータとして定義されます。

そのパラメータは密度の微調整を可能にしますが、ボリュームの不透明度に事前に乗算される可能性がるため、今後は簡単のために方程式から削除します。

線形密度は、点xから点x'までのレイに沿って以下のように累積されます。

LinearDensity(x', x) = \int_x^{x'} Opacity(s) d(s)

したがって、点xからx'までのレイの長さにわたる透過率は次のように定義されます。

Transmittance(x', x) = e^{-LinearDensity(x', x)}

これが、上記で始めた密度のみのレイマーチの密度の計算方法です。

ライティングを追加するには、レイに沿った各点の光の散乱と吸収を考慮する必要があります。

これはこれらの項を入れ子にすることを含みます。

ボリューム内の点xで、w方向からの光からその点に到達する外部散乱の量は次と等しくなります。

OutScattering(x, \vec{w}) = e^{-LinearDensity(x, l)}

ここで、wはライトの方向で、lはボリュームの外側でネガティブのライトの方向に向かう点です。

-LinearDensity(x, l)の項は、光を吸収する微粒子の量を表すボリューム境界に達するまでの、点xから光への線密度の累積を表します。

これはまだその時点で可視の光量の値に過ぎず、サンプルの不透明度に基づいて吸収されたその光の割合をまだ説明していないことに注意してください。

そのためには、外部散乱の項にOpacity(x)を掛けます。

またそれは、その光がボリュームから出て戻るので、それ以上の伝送喪失を考慮していません。

喪失について考慮するには、カメラから点xへの透過率を決めなければなりません。

単一の点に対して記述するのではなく、点xから点x'までのレイwに沿ってどれだけの外部散乱が見えるかを記述する修正関数としてTotalOutScattering(x', w)を作ることができます。

TotalOutScattering(x', \vec{w}) = \int_x^{x'} OS(s, \vec{w}) * T(x', s) d(s)

上記のOSとTはそれぞれ、OutScatteringとTransmissionの項の略語です。

OSは、追加するのを忘れていたOpacity(s)を掛けるべきですが、のちどほ式を再作成するつもりです。

この関数は、ビューのレイに沿った、ボリュームを通過するすべての点からの外部散乱の合計を返します。

実際には展開された形式で書き出すことがあまりにも厄介なので、コード自体を扱い始めるかもしれません。

外部散乱の項たちは、暗黙的に初めにライトカラーと拡散色で乗算されています。

伝統的に、他の論文でRadiance(L)と書かれた方程式を目にしたことがあるかもしれませんが、それは除外しています。なぜなら、radianceは、基本的にSceneColor * FinalOpacityであらわされる、背景色がボリュームに伝達する量を考慮しなければならないからです。

やや恣意的に決めた理由により、ここではそれを数学に加えません。


  • 1) 背景色は合成しません。代わりに、AlphaCompositeブレンドモードを使用して不透明度をプラグインします。


  • 2) 背景色をぼかしたり散乱させたりすることは実際にはありません。そのため、この項についてあまり話しすぎることはありません。完全な数学についてのさらなる詳細については、Shopfを見てください。このページの数学の多くは、そこでの方程式に基づいていますが、ギリシャ語の記号の代わりに本物の単語を使用し、関係をより単純な方法で説明することで、アーティストをより親しみやすくしています。



シャドウボリュームコード例

float numFrames = XYFrames * XYFrames;

float curdensity = 0;
float transmittance = 1;
float3 localcamvec = normalize( mul(Parameters.CameraVector, Primitive.WorldToLocal) ) * StepSize;

float shadowstepsize = 1 / ShadowSteps;
LightVector *= shadowstepsize;
ShadowDensity *= shadowstepsize;

Density *= StepSize;
float3 lightenergy = 0;

for (int i = 0; i < MaxSteps; i++)
{
float cursample = PseudoVolumeTexture(Tex, TexSampler, saturate(CurPos), XYFrames, numFrames).r;

//Sample Light Absorption and Scattering
if( cursample > 0.001)
{
float3 lpos = CurPos;
float shadowdist = 0;

for (int s = 0; s < ShadowSteps; s++)
{
lpos += LightVector;
float lsample = PseudoVolumeTexture(Tex, TexSampler, saturate(lpos), XYFrames, numFrames).r;
shadowdist += lsample;
}

curdensity = saturate(cursample * Density);
float shadowterm = exp(-shadowdist * ShadowDensity);
float3 absorbedlight = shadowterm * curdensity;
lightenergy += absorbedlight * transmittance;
transmittance *= 1-curdensity;
}
CurPos -= localcamvec;
}

return float4( lightenergy, transmittance);

ご覧のように、基本的なシャドウイングを追加するだけで、単純な密度のみのトレーサーにかなりの複雑さが追加されます。

このバージョンでは、カメラベクトルとライトベクトルはステップサイズによってループの外で事前に、最初に乗算されます。

これはすなわち、シャドウトレースは高価なシェーダを作り、できるだけ多くの処理をループの外(特に内側のループ)に移動したいためです。

現在の形式では上記のシェーダコードはまだとても遅いです。

ひとつの最適化を追加しました。シェーダは、不透明度が0.001より大きい場合にのみボクセルを評価します。

これは潜在的に、ボリュームテクスチャが多くの空白を持つ場合に、多くの時間の節約を可能にしますが、ボリュームが全体に書かれている場合はまったく役に立ちません。

実践的なシェーダを作るにはさらなる最適化が必要です。

上記バージョンの最大の問題は、すべての密度サンプルにすべてのシャドウステップが実行されることです。

つまり、64密度ステップと64シャドウステップを使うと、4096回ものサンプルになります。

なぜなら、疑似ボリューム関数は2回のルックアップを必要とするので、それはシェーダがピクセルごとに8192回のテクスチャルックアップを実行することを意味します!

これはとても悪いです。しかし、レイがボリュームから抜けるか、あるいは完全に吸収された場合に早期にやめることで大幅に最適化することができます。

最初のパートは、各シャドウイテレーション中に、レイがボリュームから離れたかをチェックすることで処理できます。

こんな感じになります。

if (pos.x > 1 || lpos.x < 0 || lpos.y > 1 || lpos.y < 0 || lpos.z > 1 || lpos.z < 0) break;

このチェックは機能しますが、シャドウループは何度も実行されるため、かなり遅くなります。

ボックス形状の密度の反復回数を事前に計算した方法と非常によく似ているので、代わりに各シャドウループの前にシャドウステップのいくつかを事前に計算することを試みました。

驚くことに、もっとも遅い方法であると判明しました。

今までのところ、シャドウループを早期終了させるために見つけたもっとも速い方法は、単純なボックステストの数学を使うことです。

float3 shadowboxtest = floor(0.5 + (abs(0.5 - lpos)));

float exitshadowbox = shadowboxtext.x + shadowboxtest.y + shadowboxtest.z;

if (exitshadowbox >= 1) break;

次に追加する必要があるのは、吸収率のしきい値による早期終了です。

通常これは、透過率が一度でも0.001のような小さな値になったらシャドウループを抜けることを意味します。

この閾値が大きいほど、アーティファクトが目立ち、この値は見た目的に許容できる範囲でできるだけ大きく調整する必要があります。

各点でのライトの透過率に不透明度の逆数を掛けてシャドウマーチングループを作成すると、イテレーションごとに透過率が暗黙的に分かり、しきい値のチェックは簡単なチェックになります。

if (transmittance < threshold) break;

ただし、実際にはシャドウのイテレーション中に透過率を計算しているわけではありません。

最初の密度だけの例のように、線形密度を累積しています。

これは、できるだけ安価になるようにシャドウループを作成する努力です。シャドウの累積ごとに1回の加算を実行するほうが、2回の乗算と1-xを実行するよりもはるかに安価だからです。

これは、透過率ではなく距離からシャドウのしきい値を決めるのに、なんらかの数学を使用する必要があることを意味します。

そうするには、$e^{(-t * d)}$で計算された最終的な透過率の項を単純に反転します。

つまり、透過率がしきい値よりも小さくなるであろうtの値について決定したいということです。

ありがたいことに、これはまさにlog(x)関数のことです。

logのデフォルトの基数はeです。

これは「eを何乗したらxになるか」という質問に答えてくれます。

つまり、透過率が0.001より小さくなるtの値を知るために、次のように計算することができます。

DistanceThreshold = -log(0.001) / d;

d = 1とユーザが定義すると仮定して、透過率0.001へ到達するのに必要とされる、6.907755という値の線形の累積を与えます。

これをシェーダコードに追加するのは次の行です。

float shadowthresh = -log(ShadowThresold) / ShadowDensity;

ここで、ShadowThresholdはユーザ定義の透過率のしきい値で、ShadowDensityはユーザ定義のシャドウ密度の乗数です。

この行はshadowstepsizeとShadowDensityによって乗算された行のあと、ループの上に配置する必要があります。


シャドウコードをアップデート

シャドウの終了と透過率のしきい値、およびループの外側での最終的な部分ステップの評価(これも同じシャドウステップを実行する必要があります)を追加すると、次のコードとなります。

float numFrames = XYFrames * XYFrames;

float accumdist = 0;
float curdensity = 0;
float transmittance = 1;
float3 localcamvec = normalize( mul(Parameters.CameraVector, Primitive.WorldToLocal) ) * StepSize;

float shadowstepsize = 1 / ShadowSteps;
LightVector *= shadowstepsize;
ShadowDensity *= shadowstepsize;

Density *= StepSize;
float3 lightenergy = 0;
float shadowthresh = -log(ShadowThreshold) / ShadowDensity;

for (int i = 0; i < MaxSteps; i++)
{
float cursample = PseudoVolumeTexture(Tex, TexSampler, saturate(CurPos), XYFrames, numFrames).r;

//Sample Light Absorption and Scattering
if( cursample > 0.001)
{
float3 lpos = CurPos;
float shadowdist = 0;

for (int s = 0; s < ShadowSteps; s++)
{
lpos += LightVector;
float lsample = PseudoVolumeTexture(Tex, TexSampler, saturate(lpos), XYFrames, numFrames).r;

float3 shadowboxtest = floor( 0.5 + ( abs( 0.5 - lpos ) ) );
float exitshadowbox = shadowboxtest .x + shadowboxtest .y + shadowboxtest .z;
shadowdist += lsample;

if(shadowdist > shadowthresh || exitshadowbox >= 1) break;
}

curdensity = saturate(cursample * Density);
float shadowterm = exp(-shadowdist * ShadowDensity);
float3 absorbedlight = shadowterm * curdensity;
lightenergy += absorbedlight * transmittance;
transmittance *= 1-curdensity;
}
CurPos -= localcamvec;
}

CurPos += localcamvec * (1 - FinalStep);
float cursample = PseudoVolumeTexture(Tex, TexSampler, saturate(CurPos), XYFrames, numFrames).r;

//Sample Light Absorption and Scattering
if( cursample > 0.001)
{
float3 lpos = CurPos;
float shadowdist = 0;

for (int s = 0; s < ShadowSteps; s++)
{
lpos += LightVector;
float lsample = PseudoVolumeTexture(Tex, TexSampler, saturate(lpos), XYFrames, numFrames).r;

float3 shadowboxtest = floor( 0.5 + ( abs( 0.5 - lpos ) ) );
float exitshadowbox = shadowboxtest .x + shadowboxtest .y + shadowboxtest .z;
shadowdist += lsample;
if(shadowdist > shadowthresh || exitshadowbox >= 1) break;
}
curdensity = saturate(cursample) * Density;
float shadowterm = exp(-shadowdist * ShadowDensity);
float3 absorbedlight = shadowterm * curdensity;
lightenergy += absorbedlight * transmittance;
transmittance *= 1-curdensity;
}

return float4( lightenergy, transmittance);

ここまでで、ひとつのディレクショナルライトによるセルフシャドウが行える、機能する半透明のレイマーチャーが手に入りました。

上記のシャドウステップは、サポートされている追加のライトごとにリピートする必要があります。

このコードは、各シャドウ項に加えて二乗の逆数による減少を計算することによって、ディレクショナルライトに加えてポイントライトを簡単にポイントライトをサポートすることができますが、CurPosからライトへのベクトルは密度サンプルごとに計算する必要があります。


アンビエントライト

これまでのところ、単一のライトからの外部散乱だけを扱っているのみです。

一般的に、ライトが完全に影になるとになるとボリューム内で平らになり、見栄えがよくありません。

通常、これに対処するためにある種のアンビエントライト項を追加します。

アンビエントライトを処理する方法はたくさんあります。

ひとつの方法は、ディープシャドウマップのような、ボリュームテクスチャの内側の雰囲気を事前計算することです。

このアプローチの欠点は、アンビエントライトが固定されたままになるためボリュームを回転してインスタンス化できないことです。

リアルタイムなアプローチは、ボクセルごとにいくつかのレイをキャストしてシャドウイングを推定することです。

これは、オフセットサンプルをひとつ追加することで成すことができますが、平均のサンプルを追加するごとに結果はよくなります。

事前ベイクされたものよりも動的なアンビエント項がいいもうひとつの理由は、複数のボリュームテクスチャをプロシージャルに積み重ねることを計画してる場合です。

このひとつの例は、Horizon Zero Dawnのクラウド論文に記述されています。

この論文では、ひとつのボリュームテクスチャは領域全体のユニークな詳細の細かな形状を記述し、ふたつめのタイリングボリュームテクスチャはベースボリュームの密度を調整するために使われます。

このようなアプローチは現在の限られた解像度において、ボリュームレンダリングのテクニックとしてとても強力です。

ブレンドの調整を適用することは、より詳細なアピアランスを生成する優れた方法ですが、それは事前計算されたライティング法がボリュームテクスチャの組み合わせによって起こる新しい詳細にマッチしないことを意味します。

AmbientLightの適用例

これはアンビエントオクルージョンを推測するための3つの追加オフセットサンプルです。

これはメインループ内で透過率が乗算されたあとに発生します。

//Sky Lighting

shadowdist = 0;

lpos = CurPos + float3(0,0,0.05);
float lsample = PseudoVolumeTexture(Tex, TexSampler, saturate(lpos), XYFrames, numFrames).r;
shadowdist += lsample;
lpos = CurPos + float3(0,0,0.1);
lsample = PseudoVolumeTexture(Tex, TexSampler, saturate(lpos), XYFrames, numFrames).r;
shadowdist += lsample;
lpos = CurPos + float3(0,0,0.2);
lsample = PseudoVolumeTexture(Tex, TexSampler, saturate(lpos), XYFrames, numFrames).r;
shadowdist += lsample;

//shadowterm = exp(-shadowdist * AmbientDensity);
//absorbedlight = exp(-shadowdist * AmbientDensity) * curdensity;
lightenergy += exp(-shadowdist * AmbientDensity) * curdensity * SkyColor * transmittance;

ふたつのコメントアウトした項は、使用される一時的な数を削減するたんなる試みです。

すべてのコードと同じ結果になります。


消光色

ライトカラーをシャドウ項に適用するのは、密度サンプルごとに1回だけです。

このようにして、散乱の深さ応じてカラーの変更ができません。

実際の生活での雲からの散乱は、光の波長を均等に散乱する散乱がほとんどで、つまり、単色散乱は雲にとって悪くありません。

それでも、色付きの吸光はSahdowDensityパラメータをV3に置き換えるだけで、液体の吸光スペクトル、夕焼けのIBLレスポンス、または芸術的効果をエミュレートできます。

見せたい色でShadowDensityを分けます。

マテリアルの全体像は以下です。

フェーズ関数がライトカラーに追加されたことに注意してください。(その関数はengine\contentに存在していますが、関数ライブラリに公開されていません)

フェーズ関数がディレクショナルライトだけに分離され、アンビエントライトに影響を与えないようにするために、レイマーチャの出力側ではなくこの方法で行われました。


追加のシャドウイングオプション

前の投稿で説明したオブジェクトごとのカスタムな深度ベースのシャドウマップなど、様々なシャドウイング法のサポートを追加することができます。

それら全体の解決策はここでは機能しますが、深度ベースのシャドウマップはボリュメトリックなものにはよく見えません。なぜなら、シャドウは高価なカスタムブラーを実行しないため、パキっとした見た目になるためです。(思い出してください。すでにとても高価なネストループの中にいることを)

今のところ、距離フィールドシャドウを有効にすることを試してきました。

距離フィールドシャドウは、追加コストなしにソフトな影を作ることが出来るためボリュメトリックなものにとても合っています。

欠点は、体積測定のために何回もグローバルな距離フィールドを参照することは極めて高価で、それらの距離フィールドの分解能はあまり高くないということです。

もし980以上のGPUを持っている場合のみ試してみてください。

距離フィールドシャドウを追加するには、ループの外側でワールド空間のライトベクトルを渡すか、再計算する必要があります。

float3 LightVectorWS = normalize(mul(LightVector, Primitive.LocalToWorld));

そしてメインループの内側は、シャドウステップの直後に置かれます。

float3 dfpos = 2 * (CurPos - 0.5) * Primitive.LocalObjectBoundsMax.x;

dfpos = TransformLocalPositionToWorld(Parameters, dfpos).xyz;
float dftracedist = 1;
float dfshadow = 1;
float curdist = 0;
float DistanceAlongCone = 0;
for (int d = 0; d < DFSteps; d++)
{
DistanceAlongCone += curdist;
curdist = GetDistanceToNearestSurfaceGlobal(dfpos.xyz);

float SphereSize = DistanceAlongCone * LightTangent;

dfshadow = min( saturate(curdist / SphereSize) , dfshadow);

dfpos.xyz += LightVectorWS * dftracedist * curdist;
dftracedist *= 1.0001;
}

そしてdfshadow項に吸収されライトが掛けられます。


時間的ゆらぎ

スライシングアーティファクトがステップ数が多いと発生することがあり、またボリュームテクスチャの解像度によっても起こるときがあります。

ステップ数を少なくすると、上記で説明したように平面にスナップさせればイメージを向上することができますが、カメラのモーションによってスライスの回転によるスライシングアーティファクトが発生します。

時間的ゆらぎは基本的に、毎フレーム開始位置をランダムに回り、結果をなめらかにします。

ゆらぎ表面の前に動くオブジェクトがない限り、それは機能します。

以前、これにDitherTempralAAマテリアル関数を使いましたが、4.12でUE4に追加された、Marc Olanoによる改善された擬似乱数関数のおかげで、今ではより安価でよりよい方法があります。

それを加味した、これらの3行を追加します。(localcamvecはこの時点でステップサイズを事前に掛けられている点に注意してください)

int3 randpos = int3(Parameters.SvPosition.xy, View.StateFrameIndexMod8);

float rand =float(Rand3DPCG16(randpos).x) / 0xffff;
CurPos += localcamvec * rand.x * Jitter;


さいごに

4.14ではマテリアルコンパイラがピン間で命令を共有するのを防ぐ回帰が導入されましたため、以前は4.13.2を使うことを勧めていました。

つまり、不透明度と発光色をつなぐことはレイマーチ関数すべてで2回行われることを意味します。

4.14でのひとつの回避策は、1.0不透明度を使い、発光色とシーンカラーの間で不透明度によるlerpを使用することです。

(まだメモがありますが、このブログテンプレートの文字数制限に引っかかったため、それ以上は単純に省略します。それらの情報はフォローアップで投稿しようと思います。すべての参考文献は網羅しません)