コポうぇぶろぐ

コポコポによる備忘録&Tipsブログ

【UE4】Object Space Raymarching (Material Editor)

はじめに

本記事は UE4 Advent Calendar 2019 の 11日目 の記事です。
前日の記事は @ruyoさん の「【UE4】Editor画面でもキャラを可愛くしたい」でした!

qiita.com

本記事では、UE4 (4.22.3) で Object Space の Raymarching を試した際の記録になります。今年度から UE4 を使い始めたので、描画周りの学習のメモのようになります。
f:id:coposuke:20191201210152j:plain

ソースはこちらです。よろしければご覧ください。
github.com

Raymarching とは

Raymarching はレイトレーシングの一種です。レイトレーシングといえば…

  1. カメラ(視点)からレイを飛ばして
  2. オブジェクトとの衝突地点を算出して
  3. 色を決める(複雑)

かなりざっくりですが大体がこのようなアルゴリズムになっています。
ここの2の「衝突地点の算出」で…

  • オブジェクトとの衝突地点を算出する
  • オブジェクトに向かって徐々に進んでいき衝突や濃度を算出する

前者がレイトレと聞いて最初にイメージするものですが(一般的?)、
後者のように徐々に進んでいくタイプの技法もあります。
Raymarching はその後者の技法になります。

どのように近づいていくかも様々アルゴリズムがありますが、
本記事では Sphere Tracing を用いております。

アルゴリズムは長文となってしまったため割愛いたします。
とても分かりやすく解説されている記事がございますので、
気になる方はご覧ください。(先駆者様のお力を頂きます)
qiita.com

Object Space Raymarching とは

レイトレースは、頂点データを作って渡して座標変換してラスタライズして…といったゲーム等でよくあるレンダリングパイプラインを必要としませんが、レンダリングパイプラインの中のフラグメントシェーディング(ピクセルシェーディング)にて、レイトレースを行うことができます。

つまり、BoxやSphereのポリゴンを描画するピクセルシェーダでレイトレースをすることで、計算量を緩和しつつ、Transoform で姿勢調整が可能になります。このようにオブジェクト空間でレイトレースするので、Object Space Raymarchingと呼ばれています。

いつもお世話になっております記事(先駆者様の方々のお力を頂きます)
i-saint.hatenablog.com
tips.hecomi.com

目的

UE4 で Object Space Raymarching を試してみた目的は…

  • UE4 の描画フローの確認
  • Material Editor で出来ること/出来ないことの確認
  • Material に対して Buffer を渡せるかの確認
  • USF(Unreal Shader File)で出来ることの確認

これらの調査をしつつ、最終的に目指した絵が下記の動画です。

Object Space Raymarching in UE4

今回は Deferred Rendering(UE4 デフォルト) で Object Space Raymarching を Material Editor で行う方法の紹介となりますので、本記事ではこれらの調査記録は記載いたしません。別記事にて取り扱いたいと思いますので、気になった方は是非ご覧ください。

Raymarchingの準備をする

前述の通り Material Editor で実装していきますので、Material の設定をしていきます。

Material Domain Object Space なので Static Mesh 等で使える Surface
Blend Mode レイが衝突しない箇所は背景のままにしたいので Mask
Shading Model 影を落とすために Default Lit (詳細は後程)
Two Sided カメラがShapeに食い込むケースは考慮しないので今回は Disable

f:id:coposuke:20191129031033j:plain

次に、レイを進めるためにカメラからピクセルまでの視線ベクトルを準備していきます。ピクセルの座標は Absolute World Position ノードから取得できるので、カメラの座標 Camera Position ノードからカメラの座標を取得し、カメラからのレイ(正規化されたもの)を算出します。

vec3 rayDirection = normalize( AbsoluteWorldPosition - CameraPosition );

f:id:coposuke:20191129031517j:plain

これがカメラからのレイになります。
「これではワールド空間のレイなのでは?」と疑問に思われると思いますが、今回は諸事情によりワールド空間でレイを進行していきます。通常 Object Space Raymarching は、カメラの位置やレイはローカル空間(オブジェクト空間)に置き換えて計算します。それにより Transform による姿勢の変更がしやすいのですが、今回はメタボールがゴールだったのでワールド空間でいきます。

最後に『おまけ(Local Space)』にてオブジェクト空間もありますので、ガッカリしないでください!

Sphereを描画する(Diffuse)

早速、ワールド座標(0, 0, 0)を中心に Sphere を描画してみます。
レイを徐々に進めていくためにループ系のノードが必要になるのですが、Material Editor には存在しないので、カスタムノードで for文 を用いて実装します。レイは Sphere からの距離の長さだけ進めていきますので…

// 距離 = レイの座標 - (0, 0, 0) - 半径;
float dist = length(rayPosition) - 100.0;

dist の長さだけレイを進めていきます。
カスタムノードは March と名前を付け、下記の Code を施します。

float rayDist= 0.0;
float3 rayPos = rayStartPos;

const float ITERATION = 128;
for(int i=0 ; i<ITERATION ; ++i)
{
    rayPos += rayDir * rayDist;

    rayDist = length(rayPos) - 100.0; // sphere

    if(rayDist < 1.0e-4) // ほぼぶつかっている距離なら衝突判定
	return float4(rayPos, 1);
}

return float4(0, 0, 0, 0);

戻り値 float4 の w成分 に衝突判定、x,y,z成分 に衝突座標を入力しています。
なので BrakeFloat4Components した w成分(A) を Output の BaseColor に接続すると、白い円(1.0)と黒い背景(0.0)に分離されていることが確認できます。
f:id:coposuke:20191129172506j:plain

Output の Opacity Maskに接続すると、不要な背景が消え、円だけが表示されるようになります。

Sphereを描画する(Normal)

陰影がつくように Normal 情報を計算します。
raymarching では、衝突した座標から上下左右前後に微妙にずらした地点の Sphere との距離を求め、各XYZ軸の距離の傾きを求める(偏微分)ことで法線を算出します。Sphere との距離計算を何度もするので、関数化したい所なのですが、カスタムノードにそのような機能はありません…

心が折れかかりましたが、調べると先駆者様がいらっしゃいました。HLSL変換時に定義された関数を使うという裏技があるようです。
nkdtr.hatenablog.com

こちらの記事にもあるように、よほどの事情がない限りはこのような実装は避けたほうがいいと思いますが、今回はこれがないとやってられないので、ヤっちゃいます。まずは距離関数のカスタムノードを Map という名前で作成します。

return length(rayPos) - 100.0;

f:id:coposuke:20191204010434j:plain

内容は先程の Sphere の距離計算です。カスタムノードは接続されている状態でないと関数が定義されないので、ダミーカスタムノードに集約して使っている状態を作ります。
f:id:coposuke:20191130005230j:plain

こちらを Save して Window > Shader Code > HLSL Code を確認します。すると…

MaterialFloat CustomExpression0(FMaterialPixelParameters Parameters,MaterialFloat3 rayPos)
{
return length(rayPos) - 100.0;
}

このような関数が見つかるかと思います。これが先程の Map の実装になります。関数名をご覧になるとお気づきになるかと思いますが、UE4側で自動的に命名されます。CustomExpression に続く番号はノードのつなぎ方でナンバリングが変わったりするので、序盤かつ使用順を意識しながら組み立てていきます。

では、この関数を使って法線を算出するカスタムノードを ComputeNormal という名前で作成します。

const float E = 1e-3; // Epsilon
return normalize( float3(
    CustomExpression0(Parameters, pos + float3(E, 0, 0)) - CustomExpression0(Parameters, pos - float3(E, 0, 0)),
    CustomExpression0(Parameters, pos + float3(0, E, 0)) - CustomExpression0(Parameters, pos - float3(0, E, 0)),
    CustomExpression0(Parameters, pos + float3(0, 0, E)) - CustomExpression0(Parameters, pos - float3(0, 0, E))
) );

戻り値 float3 を Output の Normal に接続します。Shading Model が Default Lit なので、これで陰影がつくようになりました。
f:id:coposuke:20191130005440j:plain

DirectionalLight による影はこの後調整しますので、ひとまずはスルーします。Box のエッジが明るくなっているのは深度情報によるもので、こちらもこの後調整します。

距離を関数化したので March カスタムノードも忘れず変更します。

...
for(int i=0 ; i<ITERATION ; ++i)
{
    rayPos += rayDir * rayDist;

    rayDist = CustomExpression0(Parameters, rayPos); // ←ここ

    if(rayDist < 1.0e-4)
	return float4(rayPos, 1);
}
...

Worldに配置する

球が描画できるようになったので、実際に World に置いてみます。
Scale を 5倍にした Sphere を設置し Material に作成したマテリアルを設定します。
f:id:coposuke:20191130022543j:plain
f:id:coposuke:20191130172315g:plain

しかし、座標 (0, 0, 0) を中心とした球しか描画されません。これは先程作った Map カスタムノードが (0, 0, 0)座標を前提とした作りになっており、尚且つ Absolute World Position や Camera Position をローカル座標に変換して計算していないからです。今回は ワールド座標系 を元に作成を進めておりますので、Sphere の中心が Actor の座標となるようにする必要があります。

Material Editor では Actor World Position ノードで Actor の座標が取得できます。しかし、Map カスタムノードは March や ComputeNormal 等のコード内で関数として利用するので、引数でノードを繋ぐ方法は得策ではありません。そこで、隠し? Constant Buffer (UE4 ではUniform Buffer)にも情報があったのでそれを利用します。

float3 pos = GetPrimitiveData(Parameters.PrimitiveId).ActorWorldPosition;
return length(rayPos - pos) - 100.0;

Map カスタムノードをこのように変更します。これで Actor の座標を追従するようになりました。
f:id:coposuke:20191130183802g:plain

深度を調整する(前後関係)

World に Box Static Mesh を複数個設置し、作ったマテリアルを適応してみると、Static Mesh 同士の前後関係にずれが生じています。これは raymarch で描画している Sphere の深度ではなく、Box Mesh の深度情報を用いているからです。しかし Output の Pixel Depth Offset を使うことで深度情報を書き換えることができます。

指定するオフセットは本来のメッシュ( Box Mesh )の位置からのオフセットになります。本来の位置はレイの開始地点でもある Absolute World Position から取得できるので、 raymarch で行きついた座標との距離を計算するだけです。
f:id:coposuke:20191130231207j:plain

ただし、そのまま Pixel Depth Offset に渡すだけだと、黒いノイズが入ってしまう不具合に遭遇しました。原因が特定できていないのですが、Floor で小数点以下を切り捨てることで問題ない見た目になったので、ヨシ!としています。(float じゃなくて fixed が使われて精度が落ちている?)
f:id:coposuke:20191130231222j:plain:w400

影を調整する

いよいよ最後の影の調整です。
f:id:coposuke:20191201003205g:plain
※床に映っている影が大変なことになっていますね

UE4 では Cascade Shadow Map で影を描画していますので、Shadow Map を作るカメラの画角 Orthographic に対応する必要があります。カメラの画角は Projection Matrix (View.ViewToClip) で判断できそうですが、シャドウ用のマクロ SHADOW_DEPTH_SHADER で判断する雑な方法でやります。

カスタムノード を IsShadowDepthShader という名前で作成します。

#ifdef SHADOW_DEPTH_SHADER
     return 1;
#else
     return 0;
#endif

通常なら0、シャドウなら1 を返すものを作りました。シャドウが orthographic ということが前提となった設計になりますが、Lerp で Ray を調整します。

f:id:coposuke:20191201004054j:plain

この実装でそれらしい影が床に落ちるようになりました。
f:id:coposuke:20191201004123g:plain

しかし raymarch による Sphere にも影が落ちてしまっています。影が落ちるということは「シャドウマップの距離の方が短い」ということなので、SHADOW_DEPTH_SHADER の時だけ Pixel Depth Offset を若干長めにとる対策を講じたのですが、どうやらシャドウカメラ上は Pixel Depth Offset が機能していないようです。色々試行錯誤しましたが、残念ながらこの不具合に対する完璧な解決方法は見つかりませんでした…。なので苦肉の策として、World Position Offset で頂点を動かすような対応を考えました。

しかしそう簡単にもいかず、新たな地雷を踏んでしまいました。頂点を動かすということは当然 Vertex Shader となりますが、先程定義した Distance Function カスタムノードの第一引数の型をよく見ると FMaterialPixelParameters です。Vertex Shader では FMaterialVertexParameters の型しか取り扱えないので、Distance Function が使えない…つまり頂点をどのくらい動かすか分からないという状況に陥ってしまいました。(絶対にVertex用にMap関数を再定義したくないマン)
f:id:coposuke:20191201004856j:plain

もう面倒くさくなってしまったので、適当な定数で距離を調整するカンジにしました。
f:id:coposuke:20191201005335j:plain

今回は描画周りの勉強が主目的だったので、この絵で完成といたしました。
f:id:coposuke:20191201005617g:plain

おまけ(メタボール&テクスチャ)

長くなってしまったので、別の記事にてまとめました。Map の計算が変わったり、複数の Actor の渡し方の実装が加わっただけですが、気になった方は是非ご覧ください。
coposuke.hateblo.jp

おまけ(Local Space)

冒頭にもございましたが、今回は World Space 上でレイを計算していたため、Actor の座標をわざわざ取得して Sphere の位置を調整していました。しかし、Local Space 上で計算すれば Actor が中心となるので、Actor / Component 側から座標や姿勢の調整がしやすくなります。
f:id:coposuke:20191201025817g:plain

ワールド座標系 となっていたノードに TransformPosition あるいは Transform でローカル座標系に変換します。TransformPosition は移動込の変換で、 Transform は向き(回転)のみの変換に使われます。ここで注意点として、 Output の Normal はワールド座標系である必要があるので、最後に変換して渡しています。
f:id:coposuke:20191201025005j:plain

まとめ

軽い気持ちで始めましたが、だいぶ苦戦しました。

描画フローに処理を挿し込むといったことが難しく、Custom Nodeも微妙に痒い所に届かなかったり、なかなか思ったことを実現するのが難しい印象を持ちました。

UFS で 好きなタイミング で G-Buffer に読み書きできたら遊べそうだな~と思ったのですが分らず…(情報求ム…)描画周りの情報が少ないことがちょっと辛かったです。

明日

明日は dgtanakaさん の「MaterialExpressionのひみつ」です!