UEのマテリアル上でレイトレーシングしてみた

はじめに

次の画像を御覧ください。

intro lit

4 つの球がワールド上に配置されている用に見えます。しかし、これを Unlit 表示にすると次のようになります。

intro unlit

何か不自然ですね。ではワイヤフレーム表示にしてみます。

intro wireframe

なんと、色がついた 3 つの球はポリゴンによるものではなく、板ポリに適用されたマテリアルによって描画されたものでした。本物のポリゴンによる球は白だけだったのです。

趣旨

UE のマテリアルだけを使って、簡易的な球のレイトレーシングを実装するサンプルを作ってみました。 また、UE のマテリアルで利用可能な機能を活用して、以下のようなことが実現されています。

  • マテリアル上に描画された空間が連続して見えるように、使用中の CameraActor 位置に基づいた描画を行う
  • SkyLight の位置に基づいた、周囲のオブジェクトから浮かない簡易的な陰影づけ

実際に動かしてみると、こんな感じです。

完全にやってみたくてやった遊びなので実用性はあまりないですが、マテリアルのいろんな機能を使うサンプルとしては楽しいと思います。そんなに難しいことも出てきません。

環境

  • Engine Version: 5.0.0 Early Access 2

作成に用いる考えと準備

レイトレーシングに使う座標系

レイトレーシングを行うには座標空間が必要です。今回は、マテリアルが適用されたオブジェクトの中心位置を、レイトレーシングで描画する内部座標系の原点(0,0,0)(0, 0, 0)と決めました。 (今後、レイトレーシングに使うマテリアル内部の空間の座標系のことは内部座標系と呼びます。) これにより、マテリアルを適用したオブジェクトごとに異なる座標系上でレイトレーシングが行われることになります。

座標変換

今回の実装では、内部座標系から見たら外側の、CameraActor の位置情報などをレイの生成に利用します。当然ですが、CameraActor などから取得できるのはワールド座標系での位置情報が原則です。この情報を内部座標系での処理に利用するには、ワールド座標系 → 内部座標系の座標変換が必要となります。

ここで対象とするの 2 つの座標系は、どちらも同じ大きさ(?)の直交座標系であるため、線形代数で登場するような座標変換の手法は必要ありません。原点位置をオフセットするような演算を行うだけでよく、これは減算で実現できます。

変換のために、ToRTSpaceというマテリアル関数を作成しました。ここで、TargetPositionはワールド座標系上の変換元の点、ObjectOriginは今回の内部座標系の基準とする、ワールド座標系上でのオブジェクトの原点です。

to rtspace

今後、ワールド座標系 → 内部座標系の値の受け渡しで変換が必要なときに利用します。

レイを飛ばす始点と方向

レイトレーシングを行うためには、レイを飛ばさなければなりません。今回は image-order なレイトレーシングを考えるため、カメラ位置から、描画されるピクセル一つ一つに向かうようなレイを生成します。

すると、レイの始点はカメラ位置(ワールド座標系から内部座標系に変換されたもの)となり、レイの方向は「カメラ位置 → マテリアルが適用されたオブジェクト表面上の点位置」とできます。 この方向ベクトルはまたしても減算で手に入れることができます。得られた方向ベクトルは後々のために正規化しておきます。

レイの方向を得る処理は、CreateRayDirectionというマテリアル関数に記述しました。ここで、AbsoluteWorldPositionはマテリアルが適用されたオブジェクトの表面上の点、CameraPositionはカメラ位置です。

create ray direction

レイの始点を得る処理は、カメラ位置がそのまま始点と対応するため記述の必要がありません。

描画に必要な環境のパラメータを得る方法

カメラ位置やライト情報に基づいた描画をするには、それらの位置を取得せねばなりません。また、座標系構築のためにはマテリアルを適用したオブジェクトの中心位置が必要ですし、レイの射出方向を決めるにはそのオブジェクトの描画された表面上の任意の点の位置も必要です。

これらはすべて、UE の Material に標準で用意されているノードから取得することができます。

bulitin nodes

詳細は検索すれば出てくるので割愛するとして、概要を示します。

Camera Position

現在の Viewport の視点となっているカメラのワールド座標系における位置を取得できるノード。

SkyAtmosphereLightDirection

レベル上の DirectionalLight の方向ベクトルを取得できるノード。DirectionalLight は無限遠からの平行光線を想定した光源なので、位置に意味がなく、方向のみが取得できる。 今回の描画はこのライト情報に基づく。取得ライトのインデックスは、DirectionalLight の Atmosphere Sun Light Index プロパティと対応する。

Object Position

レベルに配置されたオブジェクトのバウンディングボックスから、その中心位置をワールド座標系に基づいて取得できるノード。

Absolute World Position (World Position)

オブジェクトの表面上の任意の点の、ワールド座標系での位置を取得できるノード。

球とレイの衝突判定

球とレイの衝突判定について詳説するには数学的な解説が必要となりますが、すでに先行する多くの有用な資料がありますので、ここでは詳説せず、経過のみを述べます。参考になりそうな資料のリンクを貼っておきます。

球は、その中心点と半径の 2 つのパラメータによって位置と形状を決定できます。これは、p\vec{p}を球面上の任意の点、c\vec{c}を球の中心点、rrを半径としたとき、以下の式で記述できることを示します。

pc=r\|\vec{p} - \vec{c}\| = r

p\vec{p}は球面上の任意の点なので、この点がレイの線上のどこかの点と共有できるならば、球とレイが衝突したことになります。 共有する点が存在するかどうかは、単純に球のベクトル方程式にレイの方程式を代入して、レイの方程式に登場したttについて解けば良いです。すると二次方程式になるので、二次方程式の解の公式に従って変形すると、以下のようになります。

A=ddA = \vec{d} \cdot \vec{d}
B=d(sc)B = \vec{d} \cdot (\vec{s} - \vec{c})
C=(sc)(sc)r2C = (\vec{s} - \vec{c}) \cdot (\vec{s} - \vec{c}) - r^2


t=B±B2ACAt = \frac{-B \pm \sqrt{B^2 - AC}}{A}

ここで、ttについて解いたの式の、ルートの内部に注目します。この式はルートの中に入っているため、値が負になってしまうと実数に解を持たなくなります。複素数の位置など登場しませんので、レイ上と球面上に共有する点はないとみなしてよいことになります。よって、ルート内の式の値を見て、それが00以上であるかを確認するだでレイが球と衝突したかどうかを判別できることになります。これを判別式DDとします。

D=B2ACD = B^2 - AC

この判別式が00以上であった場合のみ、衝突位置の計算などを行います。衝突位置は、ttについて解いた式に衝突時のパラメータを代入し、求まったttを元にレイのベクトル方程式からp\vec{p}を求めればよいです。このとき、ttを求めるのに使う式の±\pm-に変更していますが、これはレイの始点から見て近いほうの共有点さえわかれば良いためです。

p=s+td=s+BDAd      (D0)\vec{p} = \vec{s} + t\vec{d} = \vec{s} + \frac{-B-\sqrt{D}}{A} \vec{d} \;\;\; (D \geq 0)

実際に作成する

レイ

レイを作成します。準備していおいたものを組み合わせて、以下のように記述できます。 make ray

ToRTSpaceCamera Positionから取得したカメラのグローバル座標系上の位置を内部座標系の位置に変換し、Ray の始点の位置ベクトルを作成します。

CreateRayDirectionで、カメラ位置とオブジェクトの表面上の点の位置から、Ray の方向ベクトルを作成します。この方向ベクトルはカメラとオブジェクト上の点の相対的な位置に基づいており、グローバル座標系と内部座標系は基底が同じスケールの直交座標系であることから、座標変換を行わなくても適切な方向ベクトルを作成できます。

これで、レイを記述する以下の式が利用できます。 p\vec{p}がレイの線上で取り得る任意の点、s\vec{s}がレイの始点、d\vec{d}がレイの方向です。 ttがパラメータとして変化することで、レイの線上の任意の点を指し示すことができるということです。 また、レイは視線と逆方向に飛ばしても意味がないので、t>0t > 0とします。

p=s+td      (t>0)\vec{p} = \vec{s} + t\vec{d} \;\;\; (t > 0)

球の衝突判定 Custom ノード

衝突判定くらいの複雑度をもつ処理になってくると、ノードで書いた際の可読性の低下が著しくなってきます。ここでは Custom ノードを用いて HLSL コードとして記述していくことにします。

Custom ノードは以下のようなインターフェイスとしました。

hit sphere custom

入力

RayOrigin

判定を行うレイの始点を示す位置ベクトル

RayDirection

判定を行うレイの方向ベクトル

TMax

レイのベクトル方程式に登場した変数ttが取り得る最大の値。 ttが大きくなると、レイの式はより遠くの点を示すようになります。逆に、TMaxttの最大値を制限すると、それよりも遠方にある衝突点には衝突しないようにすることができます。これは、一直線上に複数のオブジェクトが存在している際の、重なり処理を行うために導入しています。

Center

球の中心点

Radius

球の半径

出力

return

衝突判定を 0/1 で表す Custom ノードはデフォルト戻り値の名称を変更できないためわかりにくくなっています。

T

衝突した場合、衝突したポイントでのレイの方程式のttの値を返す。衝突しなかった場合、入力のTMaxの値をそのまま出力する。 この出力を次回以降の同一レイの衝突判定ではTMaxとして使うことで、同一レイの線上に異なる物体が発見されたとしても、より近い場所になければ判定を無視することが可能になります。

コード

HLSL によるコードはとても短いです。

HitSphere
float3 OC = RayOrigin - Center;

float A = dot(RayDirection, RayDirection);
float B = dot(RayDirection, OC);
float C = dot(OC, OC) - pow(Radius, 2);
float D = pow(B, 2) - A*C;

if (D >= 0) {
  float curT = (-B - sqrt(D)) / A;
  if (curT > 0 && curT < TMax) {
    T = curT;
    return 1;
  }
}

T = TMax;
return  0;

衝突位置の関連値を計算

ひとつ上で作成した衝突判定を包む形で、関連する値を計算します。ここでは、内部座標系における衝突位置、衝突位置での球面の法線などを求めます。簡単な計算ですので、Sphereというマテリアル関数にノードで実装しています。

sphere mat func

以下で各値の求め方を記載します。ノードはこれに対応しているだけです。

Hit

衝突判定関数の戻り値をそのまま使っているだけです。

Position

衝突判定関数から得られた、Tのパラメータをレイの方程式に代入することで、衝突位置を得ることができます。

p=s+td      (t>0)\vec{p} = \vec{s} + t\vec{d} \;\;\; (t > 0)

Normal

球面上のレイ衝突点の法線ベクトルです。 法線は面に対して垂直な直線ですから、球の場合には球の中心から球面上の点に向かうベクトルはすべて、その点での法線と同じ方向のベクトルとなります。 正規化もしておきます。

n=pcpc\vec{n} = \frac{\vec{p} - \vec{c}}{\|\vec{p} - \vec{c}\|}

T

これも衝突判定関数の戻り値をそのまま使っているだけです。

簡易シェーディングマテリアルマテリアル関数

ここまで作成したもので、ある視点から見たときの球の形状は判定できるようになりました。ここでは球のシェーディングマテリアルを作成していきます。 シェーディングは二次的なレイのトレースによるものが望ましいですが、今回はライト方向と法線の内積を用いた簡易的なものにしました。 きちんとしたレイトレースによる色付けを考える場合にはノードだと(主に繰り返し処理が)厳しいので、もっと広範囲を HLSL で記述したほうが良いと思いますが、今回は趣旨と外れるのでやめておきました。 以下がシェーディング用のDotShadingマテリアル関数です。

dot shading

Color

ここでは、l\vec{l}をライト方向として導入します。また、PbasecolorP_\mathit{basecolor}はその点を塗るベースカラーです。

Pcolor=(ln)PbasecolorP_\mathit{color} = (\vec{l} \cdot \vec{n}) P_\mathit{basecolor}

ただし、ノード上では陰影の濃度の調整のために少し処理を追加しています。Addで乗算する内積値の下限を底上げしているだけですが、そのままでは内積値が負の値を持ってしまう特性で意味がなくなってしまうので、事前にsaturateしています。 saturateは現代の GPU ではノーコストで実行できるので、[0,1][0, 1]へのクランプではclampよりおすすめです。

複数オブジェクトの描画

先ほど作成した球の描画マテリアル関数を使えば、もう単一の球を描画することができます。しかし、せっかくなので複数のオブジェクトを描画できるようにしたいです。複数オブジェクト描画用のマテリアル関数を考えていきます。このマテリアル関数はRenderという名称とします。

render mat func

この関数はここまでの作業の集大成となっています。処理の流れは以下のようなものです。

  1. レイ情報(RayOrigin, RayDirection)、ライト方向(LightDirection)、レイのtt値の初期値(TMax)を入力として受け取る。
  2. Sphere関数で球の衝突判定をする。
  3. DotShading関数でシェーディングを行い、色を決定する。
  4. 以上の処理を 1 単位としてカスケード接続し、衝突判定結果に応じて色とノーマルをLerpで合成、全体でのオブジェクトの衝突位置をAddで統合する。
  5. 描画全体の衝突判定結果をHit、描画結果をColor、ノーマルの描画結果をNormal、この描画を通して得られたレイの最短衝突位置(を導ける変数)であるttTとして出力する。

実際に外部の環境情報を入力するマテリアル

ここまでの処理はみなマテリアル関数として記述してきましたが、すでにレイトレースに必要な処理は出揃ったので、実際にオブジェクトに適用するマテリアルを作成します。このマテリアルでは、実際のライト方向やカメラ位置などの環境情報を入力します。マテリアル名はRayTracingとしました。

raytracing mat

このマテリアルでの描画はマテリアルによって実装されているので、エンジンが持っているライティングによってシェーディングを受ける必要はありません。 そのため、マテリアルの Shading Model はUnlitにしてあります。 また、Blend Mode をMaskedとし、Renderマテリアル関数のHit出力をOpacity Maskに入力してみました。これによって、衝突が検出されなかった場所はマスクされることになり、あたかもレイトレースで描画したオブジェクトがそこに存在するかのように見せることができます。 その他、ノードの接続については、ここまでで解説したものを適切につなげているだけです。

以下はマテリアルエディタ上の View で板ポリに描画されたRaytracingマテリアルです。

result view

3 つの球があります!(板です)

まとめ

こうして作成されたマテリアルをそこらのメッシュに適用すれば、そのメッシュの輪郭の内側をスクリーンとして、実装したレイトレースによるオブジェクトを描画できます。 どのように見えるかは、記事冒頭の動画をご参照ください。 使いみちは不明ですが、UE のマテリアルの機能を色々使えて楽しいです。おわりです。

share