これはレイトレ合宿9のアドベントカレンダーの記事です。
- あまりレイトレに関連しないテーマですが、レイは飛ばしているので大目に見てください
- レイトレ合宿ではレンダラーを自作する必要があるため、ゲームエンジンは使えません
はじめに
Unreal Engine 5.2上でオブジェクトスペースのレイマーチングを実装したので、その解説をします。
レイマーチングをノードだけで実装するのは大変なので、MaterialのCustomノードを用いて複雑な処理はHLSLのコードで実装しました。
UE(Unreal Engine)のプロジェクトはGitHubに公開しています。
Object Space Raymarching in Unreal Engine 5.2#UE5 #UnrealEngine #UnrealEngine5 #Shader pic.twitter.com/42n2W87HnJ
— がむ (@gam0022) July 27, 2023
Randomization of glowing animation borders#UE5 #UnrealEngine #UnrealEngine5 #Shader pic.twitter.com/FvLbVtE9Q3
— がむ (@gam0022) July 30, 2023
読者対象
この記事は以下の読者を対象としています。
- 1週間前の自分
- レイマーチングやシェーダーの実装経験はあるが、UEは初心者の方
- UEのカスタムシェーダーやMaterial Editorに興味がある方
- UE上でノードでは難しい複雑なシェーダーを実装してみたい人
結果
まずは実装結果を紹介します。
オブジェクトスペースのレイマーチング
オブジェクトスペースのレイマーチングを実装しました。カスタムシェーダーをCubeに適用し、レイマーチングを行います。Cubeをレイマーチングのバウンディングボックスとして使用します。
オブジェクトスペースにすることで、フルスクリーンのレイマーチングと比較して処理負荷を抑えることができます。
- カメラではなくCubeの表面からレイを飛ばすことで、レイマーチングの衝突判定を少ないイテレーション回数に抑えられる
- レイマーチングの負荷の高いシェーダーの描画範囲を制限できる
パラメーターのリアルタイム編集
レイマーチングによる描画を行っているため、フラクタルのパラメーターをリアルタイムに編集できます。
▲グローのアニメーションのボーダーを乱数で散らしたバージョン
Actorのトランスフォームに追従
オブジェクトスペースのレイマーチングの実装のため、Actor(UnityのGameObjectに相当)のトランスフォームに追従します。
平行移動
平行移動の結果を見ると、UEのレンダリング結果と統合できていることがわかります。
- 他のオブジェクトと相互にライティングの影響を受けている
- 床に反射し、ライティング結果が周囲に自然に馴染んでいる
回転
拡大縮小
実装の解説
ここからは実装の解説をします。
実装の全体流れ
以下は実装の流れです。
- UE上のHLSL(.ush)シェーダー開発環境の構築
- C++の開発環境のセットアップ
AddShaderSourceDirectoryMapping
を使用してシェーダーを配置するディレクトリを登録
- UE上のレイマーチングの実装
- HLSL(.ush)でレイマーチングを実装
- MaterialのCustomノードの
Include File Paths
にHLSLシェーダー(.ush)を指定し、レイマーチングの関数を呼び出す - Customノードの計算結果をResultノードに出力し、ライティング計算はエンジン側に任せる
- 発展的な内容
- オブジェクトスペースのレイマーチングに対応
- レイマーチングの空間にカメラが潜った場合を考慮
- 他のオブジェクトの前後関係の解消
HLSL(.ush)によるシェーダー開発環境の構築
Unityでは、シェーダーファイル(.shader)をAssetsフォルダーに配置するだけで認識されますが、UEではシェーダーを配置するディレクトリを明示的にエンジンに認識させる必要があります。
シェーダーを書くためにC++のコードを記述する必要があるのは面倒ですが、仕方ありませんね。
C++の関数であるAddShaderSourceDirectoryMappingを呼び出すことで、エンジンがHLSLシェーダー(.ush)を認識できるようになります。
まずは、C++の開発環境を整える必要があります。以下の記事を参考に、UE用のC++の開発環境をセットアップしました。
C++の開発環境をセットアップしたら、次の記事の「普通の方法」を参考にして、AddShaderSourceDirectoryMappingを呼び出してシェーダーを配置するディレクトリをエンジン側に登録します。
手順を箇条書きにすると以下のようになります。
- C++プロジェクト化
プロジェクト名.Build.cs
にRenderCore
への依存関係を追加- プロジェクトにモジュール開始と終了の関数を追加
- モジュール開始のStartupModule()に下記のコードを追加
FString ShaderDir = FPaths::Combine(FPaths::ProjectDir(), "Shaders");
AddShaderSourceDirectoryMapping("/Project", ShaderDir);
このコードにより、プロジェクトの直下にある Shaders
ディレクトリに配置されたHLSLシェーダー(.ush)をエンジン側からIncludeできるようになります。
余談:昔のUEでHLSLの関数定義やincludeは大変だった
過去のUEでは、HLSLをIncludeすることができず、Customノードの展開される仕様を利用して、関数定義や#includeをインジェクションする必要があったようです。
これは大変ですね。エンジンの改善により、HLSLのシェーダー開発がより使いやすくなったことは、喜ばしい進化と言えるでしょう。
もんしょさんと話していたのは、カスタムノードをカッコで閉じちゃえば、あとは自由にコード書けちゃうぜ!というネタです。
— Takuro Kayumi (@TakuroKX) November 27, 2015
実はこのTweetのマテリアルもこれを使用して書かれています。https://t.co/y3P8BkDWyw pic.twitter.com/M4M5WX4u7m
Material全体
次の画像はMaterialのグラフ全体です。
このグラフでは、レイマーチングの処理をHLSLで実装し、Customノードから呼び出しています。
ノードとしては以下の処理のみ実装しています。
- カメラのレイの生成
- 前後関係の解消のためのPixel Depth Offsetの計算
- Emissiveのパターン計算
また、オブジェクトスペースのレイマーチングを行うための座標系の計算もノードで行われています。レイマーチングのCustomノードの前後にはTransformノードが接続されています。
HLSLシェーダーでレイマーチングを実装
MaterialのCustomノードのDetailsからInclude File Pathsを指定し、HLSLファイル(.ush)をインクルードします。
このとき「実際のファイルパス」と「Include File Paths」の指定に違いがある点に注意してください。
- ファイルパスの例:
D:\UnrealProjects\プロジェクト名\Shaders\Raymarching.ush
- Include File Paths:
/Project/Raymarching.ush
Raymarching.ush
のHLSLコードの実装例は以下の通りです。レイマーチングの基本的な実装方法については説明しません。
#pragma once
float sdBox(float3 p, float3 b)
{
float3 q = abs(p) - b;
return length(max(q, 0.0)) + min(max(q.x, max(q.y, q.z)), 0.0);
}
// メンガーのスポンジの距離関数
float dMenger(float3 z0, float3 offset, float scale, inout float4 ifsPosition)
{
float4 z = float4(z0, 1.0);
[loop]
for (int n = 0; n < 4; n++)
{
z = abs(z);
if (z.x < z.y) z.xy = z.yx;
if (z.x < z.z) z.xz = z.zx;
if (z.y < z.z) z.yz = z.zy;
z *= scale;
z.xyz -= offset * (scale - 1.0);
if (z.z < - 0.5 * offset.z * (scale - 1.0))
{
z.z += offset.z * (scale - 1.0);
}
}
ifsPosition = z;
return (length(max(abs(z.xyz) - float3(1.0, 1.0, 1.0), 0.0)) - 0.05) / z.w;
}
float map(float3 pos, float uniformScale, float3 mengerOffst, float mengerScale, inout float4 ifsPosition)
{
pos /= uniformScale;
float d = dMenger(pos, mengerOffst, mengerScale, ifsPosition);
d *= uniformScale;
return d;
}
// 偏微分から法線を計算
float3 calcNormal(float3 p, float uniformScale, float3 mengerOffst, float mengerScale, inout float4 ifsPosition)
{
float eps = 0.001;
return normalize(float3(
map(p + float3(eps, 0.0, 0.0), uniformScale, mengerOffst, mengerScale, ifsPosition) - map(p + float3(-eps, 0.0, 0.0), uniformScale, mengerOffst, mengerScale, ifsPosition),
map(p + float3(0.0, eps, 0.0), uniformScale, mengerOffst, mengerScale, ifsPosition) - map(p + float3(0.0, -eps, 0.0), uniformScale, mengerOffst, mengerScale, ifsPosition),
map(p + float3(0.0, 0.0, eps), uniformScale, mengerOffst, mengerScale, ifsPosition) - map(p + float3(0.0, 0.0, -eps), uniformScale, mengerOffst, mengerScale, ifsPosition)
));
}
// Ambient Occlusionを計算
float calcAO(float3 pos, float3 nor, float uniformScale, float3 mengerOffst, float mengerScale, inout float4 ifsPosition)
{
float occ = 0.0;
float sca = 1.0;
for (int i = 0; i < 5; i++)
{
float h = 0.01 + 0.12 * float(i) / 4.0;
float d = map(pos + h * nor, uniformScale, mengerOffst, mengerScale, ifsPosition);
occ += (h - d) * sca;
sca *= 0.95;
if (occ > 0.35) break;
}
return saturate(clamp(1.0 - 3.0 * occ, 0.0, 1.0) * (0.5 + 0.5 * nor.y));
}
// エッジを計算
float calcEdge(float3 p, float uniformScale, float3 mengerOffst, float mengerScale, inout float4 ifsPosition)
{
float edge = 0.0;
float2 e = float2(0.01, 0);
float d1 = map(p + e.xyy, uniformScale, mengerOffst, mengerScale, ifsPosition);
float d2 = map(p - e.xyy, uniformScale, mengerOffst, mengerScale, ifsPosition);
float d3 = map(p + e.yxy, uniformScale, mengerOffst, mengerScale, ifsPosition);
float d4 = map(p - e.yxy, uniformScale, mengerOffst, mengerScale, ifsPosition);
float d5 = map(p + e.yyx, uniformScale, mengerOffst, mengerScale, ifsPosition);
float d6 = map(p - e.yyx, uniformScale, mengerOffst, mengerScale, ifsPosition);
float d = map(p, uniformScale, mengerOffst, mengerScale, ifsPosition) * 2.;
edge = abs(d1 + d2 - d) + abs(d3 + d4 - d) + abs(d5 + d6 - d);
edge = smoothstep(0., 1., sqrt(edge / e.x * 2.));
return edge;
}
// 原点にあるサイズが100x100x100のCubeの内部にいるかどうかを判定
float isInsideCube(float3 p)
{
return sdBox(p, (50).xxx) <= 0;
}
void raymarching(
// Inputs
float3 origin, float3 ray, int raymarchingLoop,
float uniformScale, float3 mengerOffst, float mengerScale,
// Additional Outpus
inout float hit, inout float depth, inout float3 hitPosition, inout float4 ifsPosition,
inout float3 albedo, inout float3 normal, inout float ao, inout float emissive
)
{
// レイマーチング
hit = 0;
depth = 0.0;// レイの進んだ距離
float3 p = origin;// レイの先端の座標
int i = 0;// レイマーチングのループカウンター
[loop]
for (i = 0; i < raymarchingLoop; i++)
{
float d = map(p, uniformScale, mengerOffst, mengerScale, ifsPosition);
// 最短距離を0に近似できるなら、オブジェクトに衝突したとみなして、ループを抜けます
if (abs(d) < 0.1)
{
break;
}
depth += d;// 最短距離だけレイを進めます
p = origin + ray * depth;// レイの先端の座標を更新します
}
// バウンディングボックスの中にレイが留まっていればヒットしたと判定
hit = isInsideCube(p);
hitPosition = p;
float4 _ifsPosition;
if (hit)
{
// ライティングのパラメーター
normal = calcNormal(p, uniformScale, mengerOffst, mengerScale, _ifsPosition);// 法線
emissive = calcEdge(p, uniformScale, mengerOffst, mengerScale, _ifsPosition);// エッジ
ao = calcAO(p, normal, uniformScale, mengerOffst, mengerScale, _ifsPosition);// AO
}
else
{
albedo = float3(0, 0, 0);
discard;
}
}
CustomノードのDetails
CustomノードのDetailsは以下のように設定します。
CustomノードのCodeでは、raymarhcing関数を呼び出します。
albedo = objectAlbedo;
raymarching(
origin, ray, raymarchingLoop,
uniformScale, mengerOffst, mengerScale,
hit, depth, hitPosition, ifsPosition,
albedo, normal, ao, emissive
);
return albedo;
Inputs
とAdditional Outpus
には、Raymarching.ush
のraymarching
関数の全パラメーターを指定します。
パラメーターの多い関数なので手間はかかりますが、ミスのないように注意しながら1個1個指定します。
注意点として、関数のパラメーターの順序を変更したり、新しいパラメーターを追加すると、ノードの接続情報も再設定する必要があります。
繰り返しになりますが、Include File Pathsは実際のファイルパスとは異なる点に留意して、/Project/Raymarching.ush
と指定してください。
Material全体のDetails
Material全体のDetailsは以下のように設定します。
- Material Domain: Surface
- レイマーチングはボリュームレダリングの印象があるかもしれませんが、Surfaceで問題ありません
- Blend Mode: Masked
- レイマーチングの衝突判定によって形状をマスクする必要があります
- このオプションを有効にすることで、Opacity Maskを出力できます
- 交差している場合は1、交差していない場合は0を出力します
- Shading Model: Default Lit
- ライティング計算はエンジン側に任せます
- Two Sideded: ON
- カメラがレイマーチングの内部に入った場合に両面描画が必要です
Materialノード解説:カメラのレイの生成
ここからはノードの解説をします。まずはカメラのレイを生成するためのノードです。
次のようなノードでカメラのレイを生成しています。
レイは2つの3次元ベクトルで定義されます。
- origin: レイの原点
- ray: レイの向き
rayの計算
レイの方向(ray)はシンプルです。
カメラの位置と、描画しようとしているSurfaceの座標(Absolute World Position)の差分(Subtract)から計算できます。
正規化(Normalize)してから、Transform Vectorノードでワールドスペースからローカルスペースに変換しています。
今回はオブジェクトスペースのレイマーチングのため、レイマーチングのCustomノードに入力する位置や向きはすべてローカルスペースに変換する必要があります。
originの計算
レイの原点(origin)の計算には分岐(DynamicBranch)があります。
これはカメラがレイマーチングの空間の内部に潜った場合を考慮しているためです。
カメラの位置によってoriginを分岐しています。
- カメラが外部にある場合: origin = Absolute World Position
- カメラが内部にある場合: origin = カメラの位置
また、originもローカルスペースにする必要があるため、Transform Positionノードで変換しています。
カメラの内部/外部の判定はInsideCubeノードで行われています。これもCustomノードです。
Codeには isInsideCube(localPos)
と指定しています。これはRaymarching.ush
で定義された関数です。
この分岐によって、カメラがレイマーチングの外部にある場合でも内部にある場合でも、問題なく動作するようになっています。
また、「Two Sideded: ON」にしている理由は、カメラがレイマーチングの内部に入っている場合において、Cubeの裏面側でレイマーチングを描画するためです。
これによって、以下のGIFアニメーションでは、カメラがレイマーチングの空間の内部に入り込んだ場合でも正常に描画されています。
Materialノード解説:Resultノードへの出力
RaymarchingのCustomノードの出力を、それぞれResultノードに接続します。
これによりレイマーチングの衝突判定の結果をライティング計算に反映させます。
- hit
- レイマーチングの衝突判定の結果(0なら衝突しなかった、1なら衝突した)です
- Resultノードの
Opacity Mask
に接続することで、レイマーチングがヒットしなかったPixelをdiscardできます
- normal
- ローカルスペースの法線です
- ワールドスペースに変換(TransformVector)してから、Resultノードの
Normal
に接続します
- ao
- [0-1]のパラメーターなので、そのままResultノードの
Ambient Occlusion
に接続します
- [0-1]のパラメーターなので、そのままResultノードの
Materialノード解説:前後関係の解決(Pixel Depth Offsetの計算)
他のオブジェクトと重なった場合でも、前後関係を正しく解決するための工夫について説明します。
以下のGIFアニメーションのように、白い球体と重なっている場合でも、前後関係を正しく解決できています。
UEのMaterialではDepthBufferを直接書き込むことはできませんが、ワールド座標でのDepthの押し込み距離から、Pixel Depth Offsetを計算することで前後関係を解決しています。
Pixel Depth Offsetは、ワールド空間でのオフセット距離を計算する必要があります。したがって、Raymarchingの衝突点(hit)をTransform Positionノードを使用してワールド座標に変換し、Absolute World Positionとの差分(Subtract)を計算し、その距離(Length)を計算しています。
Pixel Depth Offsetでは、カメラの奥方向にのみオフセットできます。逆に手前にオフセットさせることはできません。この制約は、パフォーマンスの向上を目的としています。
カメラがレイマーチングの内部にある場合、Cubeの裏面のSurfaceからは、衝突点(hit)が手前に来るため、Pixel Depth Offsetはマイナス値となります。
しかしこのマイナス値は利用できないため、カメラが内部にある場合は、Pixel Depth Offset = 0
となるようにLerpノードで分岐しています
まとめ
- UE5.2でオブジェクトスペースのレイマーチングを実装できた
- Actorに追従し、通常のMeshのようにギズモによりマウスで配置や変形ができる
- エンジンのレンダリング機能とも破綻なく統合できた
- 床への反射やグローバルイルミネーションに統合できた
- Opacity Mask、Pixel Depth Offsetを利用して、他のオブジェクトの前後関係なども解決できた
- ノードとコードの役割分担をうまくできた
- 複雑な処理はHLSLによるコードで実装し、Customノードで呼び出す
- オブジェクトスペースのための座標変換はノードを利用することでシンプルに実装できた
- UEの細かいTipsについては別記事でまとめる予定
- 「Ctrl + 1 + 左クリック」でスカラーの定数ノードを作成できる
- VS2022プロジェクトの作成に失敗する場合、Source Code Editorを再選択すると解決することがある
- UnityとUEの座標系やスケールの違いについて
参考資料
UE4やUnity上でのレイマーチング実装の前例を以下に紹介します。これらの情報をとても参考にさせていただきました。
- 【UE4】Object Space Raymarching (Material Editor) - コポうぇぶろぐ
- UE4上でのレイマーチング実装の取り組み
- 当時は Include File Paths が存在しなかったため、Customノードの関数定義に対するハックが必要であったことなどに触れています。
- Unity HDRPのLitシェーダーを改造してレイマーチングする(GBuffer編) - なんかやる
- Unity HDRP上でのレイマーチング実装の取り組み
- Unity でレイマーチングするシェーダを簡単に作成できるツールを作ってみた
- Unity ビルドインRP・URP上でのレイマーチングの実装の取り組み