カバー株式会社 Advent Calendar 2024 の21日目の記事です。
今回はUnityのレイトレーシングについて書かせていただこうと思います。宜しくお願い致します。
なお、前回の記事は Luncoさん の Unity C#でMulticastを送受信したい でした。こちらの記事もぜひご覧ください。
■ なぜレイトレーシング?
Unityのリアルタイムによる影表現はシャドウマッピングが主流です。しかし、シャドウマッピングの技法はシャドウアクネの問題や、近接したメッシュ間での影の生成に弱い問題があります。この問題はソフトシャドウやBias調整、シャドウカスケードなどを利用することで改善することができますが、完全な解決は困難です。
この問題の解決策の1つがレイトレーシングです。下図はシンプルなライト計算にレイトレーシングを使用したものですが、近接したメッシュ間でも高品質な影が生成されているのが確認できるかと思います。
今回はこのレイトレーシングを、Unityで利用する実装についてご紹介したいと思います。
■ Unityでのレイトレーシングのサポート状況
Unityのレイトレーシングの影のサポートは現時点ではHDRPのみとなり、残念ながらBuilt-in Render PiplineやURPではまだサポートされていません。
今後、Unity 7でURPとHDRPが統合された"Unified Renderer"になることで、レイトレーシングがより利用しやすくなるかもしれませんが、これはまだ先の話になりそうです。また、Built-in Render Piplineもまだまだ現役(利用されている現場が多いという点で)ですので、HDRPが提供するレイトレーシングを利用するのは難しいのが現状です。
では、Built-in Render PiplineやURPでRaytracingが利用できないかというと、そうではありません。
まず、1つ目のRaytracingの実装方法ですが、ネイティブプラグインで直接DirectX APIを叩き、独自に管理する方法です。
GameEngineを独自に実装していた開発者であれば大好物(?)な実装方法ですが、DirectX APIとC++の両方の知識を必要とし、リソース管理もネイティブプラグイン側で行う必要があるため、実装のハードルが非常に高いものとなります。
過去にUnity Japanさんがこのアプローチでの実装をGithubで公開していますが、高速化のために自前でGPUスキニングやモーフィングまで行うなど、かなり複雑な処理を行っています。
https://github.com/unity3d-jp/RaytracedHardShadow
自由度は一番高い実装方法であり、シェーダ内で独自のライティングの実装なども可能ではあるものの、メンテナンスコストが高く、お薦めしにくい手法です。
次に、2つ目の実装方法ですが、Unityが提供するRayTracingShaderを使用した実装です。
RayTracingShaderはネイティブプラグインでは避けられないDirectXの面倒な初期周りの処理などは一切不要で、シンプルな実装になっているため、レイトレーシングの実装に集中できる扱いやすい機能です。
この機能はUnity 2022ではまだ「Experimental(試験的な機能)」でしたが、Unity 2023からこの「Experimental」が取れました。ただ、Unity 2023でも動作することは確認していますが、マニュアルには「This version of Unity is unsupported.」が表記されていますので、この表記がとれたUnity 6からの利用が安全かと思われます。
今回はこのRayTracingShaderを使用した実装について説明していきます。RayTracingShaderを使用した解説はネットでもなかなか見つけることができなかったので、是非ご活用いただければと思います。
※ 本ページでは「Unity 6000.0.30f1」を使用して検証してますので、他のバージョンでお試しになる場合はご注意ください。
■ レイトレーシングのパイプライン
RayTracingShaderの説明に入る前に、まずレイトレーシングの基本的なパイプラインを知っておくと、より理解が深まります。
下図を元に説明させていただきますが、レイトレーシングでは最初に処理の起点となる「Ray Generation Shader」からレイを生成します。この生成したレイを飛ばす必要がありますが、それがTraceRay関数になります。
引用元:
https://www.techspot.com/article/2137-next-gen-directx-12/
TraceRay関数には、Acceleration Structureと呼ばれるシーンを構成するデータ構造を指定しますが、飛ばしたレイはAcceleration Structureで管理するデータとの交差判定を行います。
この交差判定に成功した場合、最も近い点に対して「Closest Hit Shader」が呼ばれ、失敗した場合は「Miss Shader」が呼ばれます。「Miss Shader」が何に使われるのか不思議に思われる方もいるかもしれませんが、遠景の環境マップを表示させたい場合などで利用できます。
これら3つの「Ray Generation Shader」、「Closest Hit Shader」、「Miss Shader」がレイトレーシングでキーとなるシェーダになります。
それ以外のシェーダについては必ずしも必要とはなりませんが、「Any Hit Shader」はレイがヒットした全ての点で実行されるシェーダで、主に半透明などで利用します。もう1つの「Intersection Shader」は交差判定を行うシェーダになり、主に三角ポリゴン以外の形状を交差判定する場合に利用するシェーダになります。とくに、「Intersection Shader」は扱う機会は少ないかもしれません。
ここまでがレイトレーシングのパイプラインの簡単な説明でしたが、パイプラインについてはCEDEC 2019での公演の「シェーダーでかんたんにわかる!」リアルタイムレイトレーシング入門、が非常に分かりやすかったですので、こちらも併せてご参考いただければと思います。
https://cedil.cesa.or.jp/cedil_sessions/view/2097
レイトレーシングのシェーダコードの基本
それでは、実際のレイトレーシングのシェーダコードを見ていきましょう。
ここからはUnityのRatracingShaderで使用されるシェーダをベースにお話をしていきますので、DirectX標準のシェーダ(DXR)との互換性がないことにご注意ください。
まず、RatracingShaderで扱うシェーダは、毎フレーム一度だけ呼び出されるRayTracingShader.Dispatch用のシェーダと、マテリアルごとに設定可能なシェーダの2種類があります。
最初に、RayTracingShader.Dispatchから呼び出されるシェーダを公式のサンプルを見ながら解説していきます。
RayTracingShader.Dispatch用シェーダ
以下は公式のマニュアルに記載されているサンプルシェーダですが、シンプルに画面にモデルが映っている場合は白、映らない場合は黒を表示するといったものです。TraceRayは1ピクセルにつき一度だけの実行となり、カメラの先にモデルが存在するかの交差判定だけを行います。
#include "UnityShaderVariables.cginc"
#pragma max_recursion_depth 1
// Input
RaytracingAccelerationStructure g_SceneAccelStruct;
float g_Zoom; //Mathf.Tan(Mathf.Deg2Rad * Camera.main.fieldOfView * 0.5f)
// Output
RWTexture2D<float4> g_Output;
struct RayPayload
{
float4 color;
};
[shader("miss")]
void MainMissShader(inout RayPayload payload : SV_RayPayload)
{
payload.color = float4(0, 0, 0, 1);
}
[shader("raygeneration")]
void MainRayGenShader()
{
uint2 launchIndex = DispatchRaysIndex().xy;
uint2 launchDim = DispatchRaysDimensions().xy;
float2 frameCoord = float2(launchIndex.x, launchDim.y - launchIndex.y - 1) + float2(0.5, 0.5);
float2 ndcCoords = frameCoord / float2(launchDim.x - 1, launchDim.y - 1);
ndcCoords = ndcCoords * 2 - float2(1, 1);
ndcCoords = ndcCoords * g_Zoom;
float aspectRatio = (float)launchDim.x / (float)launchDim.y;
float3 viewDirection = normalize(float3(ndcCoords.x * aspectRatio, ndcCoords.y, 1));
// Rotate the ray from view space to world space.
float3 rayDirection = normalize(mul((float3x3)unity_CameraToWorld, viewDirection));
RayDesc ray;
ray.Origin = _WorldSpaceCameraPos;
ray.Direction = rayDirection;
ray.TMin = 0.0f;
ray.TMax = 1000.0f;
RayPayload payload;
payload.color = float4(1, 1, 1, 1);
uint missShaderIndex = 0;
TraceRay(g_SceneAccelStruct, 0, 0xFF, 0, 1, missShaderIndex, ray, payload);
g_Output[frameCoord] = payload.color;
}
まず登場するのが、レイトレーシングのパイプラインで説明した全ての起点となる「Ray Generation Shader」ですが、前半の処理ではレイを飛ばす向きを画面のピクセル位置とカメラの画角とワールド空間のマトリックスから計算を行います。ComputeShaderを触ったことのある方は馴染みのある処理かもしれません。
#include "UnityShaderVariables.cginc"
// Input
float g_Zoom; //Mathf.Tan(Mathf.Deg2Rad * Camera.main.fieldOfView * 0.5f)
[shader("raygeneration")]
void MainRayGenShader()
{
uint2 launchIndex = DispatchRaysIndex().xy;
uint2 launchDim = DispatchRaysDimensions().xy;
float2 frameCoord = float2(launchIndex.x, launchDim.y - launchIndex.y - 1) + float2(0.5, 0.5);
float2 ndcCoords = frameCoord / float2(launchDim.x - 1, launchDim.y - 1);
ndcCoords = ndcCoords * 2 - float2(1, 1);
ndcCoords = ndcCoords * g_Zoom;
float aspectRatio = (float)launchDim.x / (float)launchDim.y;
float3 viewDirection = normalize(float3(ndcCoords.x * aspectRatio, ndcCoords.y, 1));
// Rotate the ray from view space to world space.
float3 rayDirection = normalize(mul((float3x3)unity_CameraToWorld, viewDirection));
次に、カメラ位置を起点に、先ほど求めたレイを飛ばす向きをRayDescに設定して、TraceRayを実行します。TMinとTMaxはレイの最小範囲と最大範囲になります。
RayPayloadは交差判定で呼び出される各種シェーダへ渡される構造体ですが、初期化時点ではpayload.colorは白が設定されています。
そして、最後にレンダリング結果としてg_Outputにpayload.colorのカラーが書き込まれます。
RayDesc ray;
ray.Origin = _WorldSpaceCameraPos;
ray.Direction = rayDirection;
ray.TMin = 0.0f;
ray.TMax = 1000.0f;
RayPayload payload;
payload.color = float4(1, 1, 1, 1);
uint missShaderIndex = 0;
TraceRay(g_SceneAccelStruct, 0, 0xFF, 0, 1, missShaderIndex, ray, payload);
g_Output[frameCoord] = payload.color;
今回のサンプルでは交差判定関連のシェーダとして「Miss Shader」が使われていますが、このシェーダが呼び出された場合は、payload.colorが黒に書き換えられます。
つまり結果として、何らかモデルが存在する場合は白、モデルが存在しない場合は黒が描画されることになります。
[shader("miss")]
void MainMissShader(inout RayPayload payload : SV_RayPayload)
{
payload.color = float4(0, 0, 0, 1);
}
マテリアル用シェーダ
次に、マテリアルに設定するレイトレーシング用のシェーダについて説明していきます。少し馴染みのあるシェーダになってきているのではないでしょうか。
マテリアル用のシェーダでは、「Closesthit Shader」の記述を行っていきますが、影の処理を行わない場合は、一行で済んでしまいますので簡単ですね。
余談ですが、冒頭の「Name "Test"」の記載は、Unity 6でも一文字でも変えてしまうとモデルが真っ黒になってしまいますので、今もお約束の記述になっているようです。
SubShader
{
Pass
{
// SetShaderPass must use this name in order to execute the ray tracing shaders from this Pass.
Name "Test"
// Add tags to identify the shaders to use for ray tracing.
Tags{ "LightMode" = "RayTracing" }
HLSLPROGRAM
#pragma multi_compile_local RAY_TRACING_PROCEDURAL_GEOMETRY
// Specify this shader is a raytracing shader.
#pragma raytracing test
struct AttributeData
{
float2 barycentrics;
};
struct RayPayload
{
float4 color;
};
[shader("closesthit")]
void ClosestHitMain(inout RayPayload payload : SV_RayPayload, AttributeData attribs : SV_IntersectionAttributes)
{
payload.color = float4(1, 0, 0, 1);
}
ENDHLSL
}
}
レイトレーシングによる影のシェーダ計算
さて、いよいよレイトレーシングでの影の計算について説明していきます。まず、光源による影をレイトレーシングで計算するためには、2回のTraceRayを実行する必要があります。
1回目はすでに説明したカメラにモデルが映っているかの交差判定となりますが、2回目の交差判定では、光源方向に遮蔽するオブジェクトが存在するかの交差判定を行うことで、シャドウマッピングの代わりとなる正確な影計算を実現します。
この2回の交差判定を行うためには、遮蔽されているかを判定するために「Closest Hit Shader」で1回目に交差した点の位置情報を求める必要があります。そのためのシェーダコードが以下となります。
行っている処理はシンプルで、交差した三角ポリゴンの情報を取得して、この三角ポリゴンを形成する3つの頂点位置から交差ポイントを算出してpayloadに設定します。
[shader("closesthit")]
void ClosestHitMain(inout RayPayload payload : SV_RayPayload, AttributeData attribs : SV_IntersectionAttributes)
{
uint3 triangleIndices = UnityRayTracingFetchTriangleIndices(PrimitiveIndex());
Vertex v0, v1, v2;
v0 = FetchVertex(triangleIndices.x);
v1 = FetchVertex(triangleIndices.y);
v2 = FetchVertex(triangleIndices.z);
float3 barycentricCoords = float3(1.0 - attribs.barycentrics.x - attribs.barycentrics.y, attribs.barycentrics.x, attribs.barycentrics.y);
float3 position = v0.position * barycentricCoords.x + v1.position * barycentricCoords.y + v2.position * barycentricCoords.z;
payload.color = float4(1, 0, 0, 1);
payload.worldPos = float4(mul(ObjectToWorld(), float4(position, 1)).xyz, 1);
}
次に「Ray Generation Shader」に戻ってきます。前回説明したサンプルでは、TraceRayの実行は一度だけでしたが、今回はもう一回のTraceRayの実行が行われています。これが遮蔽を計算するためのレイの"トレース"になります。
この遮蔽の判定ですが、まずpayloadShadow.shadowを0.2(これは見た目で選んだ意味のないアバウトな数値です)に初期化しておき、遮蔽された状態をデフォルト値としています。その後、TraceRayの実行で「Miss Shader」が実行されたらpayloadShadow.shadowを1にすることで、遮蔽状態を解除します。
実際に使用した「Miss Shader」は後述しますが、2つのTraceRayに登録されているシェーダが異なっており、遮蔽判定用の「Miss Shader」では「payload.shadow = 1.0」を実行します。
そして最後にpayload.shadowを反映した最終出力の値をg_Outputに書き込みレイトレーシングの処理は完了です。
RayDesc ray;
ray.Origin = rayOrigin;
ray.Direction = rayDirection;
ray.TMin = 0;
ray.TMax = 1e10f;
RayPayload payload;
payload.color = float4(0, 0, 0, 0);
payload.worldPos = float4(0, 0, 0, 1);
payload.bounceIndex = 0;
const uint missShader = 0;
TraceRay(g_SceneAccelStruct, 0, 0xFF, 0, 1, missShader, ray, payload);
// Shadow Ray
RayDesc shadowRay;
shadowRay.Origin = payload.worldPos;
shadowRay.Direction = vecToLight;
shadowRay.TMin = 0;
shadowRay.TMax = vecToLightLength;
RayPayloadShadow payloadShadow;
payloadShadow.shadow = 0.2f;
const uint missShaderForShadowRay = 1;
TraceRay(g_SceneAccelStruct, RAY_FLAG_ACCEPT_FIRST_HIT_AND_END_SEARCH | RAY_FLAG_SKIP_CLOSEST_HIT_SHADER | RAY_FLAG_CULL_BACK_FACING_TRIANGLES, 0xFF, 0, 1, missShaderForShadowRay, shadowRay, payloadShadow);
g_Output[frameCoord] = payload.color * payloadShadow.shadow;
なお、今回使用した「Miss Shader」は以下の2つとなります。1つ目は...何もしていませんねw
2つ目のシェーダが遮蔽に使用されていますが、このシェーダが実行されることで遮蔽が行われなかった処理が行われます。
[shader("miss")]
void MainMissShader1(inout RayPayload payload : SV_RayPayload)
{
}
[shader("miss")]
void MainMissShader2(inout RayPayloadShadow payload : SV_RayPayload)
{
payload.shadow = 1.0;
}
レイトレーシングを有効にするための設定
最後にレイトレーシングを利用するためには、Direct3D12をGraphics APIとして設定する必要がありますので、この点はご注意ください。
また、ビデオカードもレイトレーシングに対応している必要がありますが、C#のスクリプトの以下の記述で判定できますので、こちらもご活用いただければと思います。
SystemInfo.supportsRayTracing
最後に
最近はグラフィックのプログラムを書く機会が少なかったため、楽しく書かせていただきました。
今回の記事はまだ入門的な内容でしたので、また機会があればより実践的な内容にも触れていければと思います。