【UE5】HLSLでScreen Space Reflectionsを自作する

# 概要
マテリアルのCustomノードを使ってSSRを実装しました。
![ssr.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/149393/56860997-55f0-7e6b-358e-11572a55a116.gif)
UE5標準機能のSSRよりクオリティは落ちますが、融通が利くので便利かもしれません。

エンジンバージョンはUE5.1を使用しました。

# 実装
ポストプロセスで実装していきます。
![スクリーンショット 2023-06-08 033054.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/149393/3770f48c-3e88-21ff-a14c-83e332a5297d.png)

グラフはシンプルにCustomノードをEmissiveに繋いでいるだけです。
SceneTextureノードはどこかで使われていないとコンパイルが通らないので刺さっています。

## コード
大まかな部分は以下の記事を参考にさせていただきました。
Unity で Screen Space Reflection の実装をしてみた
https://tips.hecomi.com/entry/2016/04/04/022550
~~~hlsl
int maxRayNum = 20;
float maxLength = 500;
float maxThickness = 500 / maxRayNum;

float3 R = ReflectionAboutCustomWorldNormal(Parameters, Parameters.WorldNormal, false);
float3 step = maxLength / maxRayNum * R;

float3 pos = LWCToFloat(Parameters.AbsoluteWorldPosition);
float2 uv = ViewportUVToBufferUV(GetViewportUV(Parameters));
float3 Color = SceneTextureLookup(uv, 14, false).xyz;

for (int i = 1; i <= maxRayNum; i++) 
{
    float3 ray = step * (i + RandFast((uv + fmod(View.GameTime,1.0)) * View.ViewSizeAndInvSize.xy));
    float3 rayPos = pos + ray;
    float4 viewRayPos = mul(float4(rayPos, 1), LWCHackToFloat(PrimaryView.WorldToClip));
    float2 rayUv = viewRayPos.xy / viewRayPos.w * float2(0.5, -0.5) + 0.5;
    float2 bufferUv = ViewportUVToBufferUV(rayUv);
    float rayDepth = viewRayPos.w;
    float gbufferDepth = CalcSceneDepth(bufferUv);

    if (max(abs(rayUv.x - 0.5), abs(rayUv.y - 0.5)) > 0.5) break;
    
    if (rayDepth - gbufferDepth > 0 && rayDepth - gbufferDepth < maxThickness)
    {
        float edgeFactor = 1.0 - pow(2.0 * length(rayUv - 0.5), 2);
        float a = pow(min(1.0, (maxLength / 2) / length(ray)), 2.0) * saturate(edgeFactor);
        a *= pow(length(rayUv - 0.5) / 0.5, 0.5);
        Color = Color * (1 - a) + SceneTextureLookup(bufferUv, 14, false).xyz * a;
        break;
    }
}

return Color;
~~~
このままCustomノードにコピペすれば動くはずです。
かいつまんで説明していきます。

## 変数の準備
初期状態の変数たち。
~~~hlsl
int maxRayNum = 20;
float maxLength = 500;
float maxThickness = 500 / maxRayNum;

float3 R = ReflectionAboutCustomWorldNormal(Parameters, Parameters.WorldNormal, false);
float3 step = maxLength / maxRayNum * R;

float3 pos = LWCToFloat(Parameters.AbsoluteWorldPosition);
float2 uv = ViewportUVToBufferUV(GetViewportUV(Parameters));
float3 Color = SceneTextureLookup(uv, 14, false).xyz;
~~~
`Parameters.AbsoluteWorldPosition`はWorld Positionノードと同じ値です。
そのままだと特殊な型になっていて使えないので、LWCToFloatでfloatに変換します。
`SceneTextureLookup(uv, 14, false)`でSceneTextureノードのPostProcessInput0と同じ値を取得しています。

## レイを飛ばす
World PositionからReflection Vectorに向かってレイを飛ばします。
~~~hlsl
float3 ray = step * (i + RandFast((uv + fmod(View.GameTime,1.0)) * View.ViewSizeAndInvSize.xy));
float3 rayPos = pos + ray;
~~~
このときノイズをかけてStep距離にランダム感を与えます。
View.GameTimeで時間的にもノイズをかけるとより良い結果になります。
Random.ushにあった`RandFast()`を使いました。

## レイの座標をスクリーン座標に変換
ここが一番難儀しました。
アンリアルはマテリアルからView Projection Matrixにアクセスできないと思っていたのですが、シェーダーコードを探してみるとそれっぽい処理が行われている箇所がいくつかあります。
~~~hlsl:LumenMeshSDFCulling.usf
OutPosition = mul(float4(WorldPosition, 1), LWCHackToFloat(PrimaryView.WorldToClip));
~~~
どうやらこの`PrimaryView.WorldToClip`というやつで変換できそうなので、流用してみました。
~~~hlsl
float4 viewRayPos = mul(float4(rayPos, 1), LWCHackToFloat(PrimaryView.WorldToClip));
float2 rayUv = viewRayPos.xy / viewRayPos.w * float2(0.5, -0.5) + 0.5;
float2 bufferUv = ViewportUVToBufferUV(rayUv);
~~~
これでレイの飛んだ位置がスクリーンではどの位置になるのかが分かりました。
可視化するとこんな感じになります。
![スクリーンショット 2023-06-08 013726.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/149393/58b24219-22bd-5199-8752-cce9ecd301b7.png)

## レイのヒット判定
GBufferの深度とレイの深度を比較します。
~~~hlsl
if (rayDepth - gbufferDepth > 0)
{
    Color = Color + SceneTextureLookup(bufferUv, 14, false).xyz;
    break;
}
~~~
レイの深度がGBuffer深度を上回った(レイがめり込んだ)らヒットしたと判定、ヒット位置のスクリーン座標でシーンカラーをサンプルして加算していきます。

## ループ処理
一連の流れをmaxRayNum分forループし、Stepを刻みます。
~~~hlsl
for (int i = 1; i <= maxRayNum; i++) 
{
    float3 ray = step * (i + RandFast((uv + fmod(View.GameTime,1.0)) * View.ViewSizeAndInvSize.xy));
    float3 rayPos = pos + ray;
    float4 viewRayPos = mul(float4(rayPos, 1), LWCHackToFloat(PrimaryView.WorldToClip));
    float2 rayUv = viewRayPos.xy / viewRayPos.w * float2(0.5, -0.5) + 0.5;
    float2 bufferUv = ViewportUVToBufferUV(rayUv);
    float rayDepth = viewRayPos.w;
    float gbufferDepth = CalcSceneDepth(bufferUv);
    
    if (rayDepth - gbufferDepth > 0)
    {
        Color = Color + SceneTextureLookup(bufferUv, 14, false).xyz;
        break;
    }
}
~~~
この時点でSSRのベースはできています。
![スクリーンショット 2023-06-08 015132.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/149393/54409f48-3246-4ab8-8dde-509d63a152f3.png)
かなりノイジーですがアンチエイリアスがいい感じに処理してくれるので問題ないです。

あとはレイが画面外に出ているかを判定、画面外に近づくにつれフェードアウトする処理を追加すれば綺麗になります。
~~~hlsl
for (int i = 1; i <= maxRayNum; i++) 
{
    float3 ray = step * (i + RandFast((uv + fmod(View.GameTime,1.0)) * View.ViewSizeAndInvSize.xy));
    float3 rayPos = pos + ray;
    float4 viewRayPos = mul(float4(rayPos, 1), LWCHackToFloat(PrimaryView.WorldToClip));
    float2 rayUv = viewRayPos.xy / viewRayPos.w * float2(0.5, -0.5) + 0.5;
    float2 bufferUv = ViewportUVToBufferUV(rayUv);
    float rayDepth = viewRayPos.w;
    float gbufferDepth = CalcSceneDepth(bufferUv);

    if (max(abs(rayUv.x - 0.5), abs(rayUv.y - 0.5)) > 0.5) break;
    
    if (rayDepth - gbufferDepth > 0 && rayDepth - gbufferDepth < maxThickness)
    {
        float edgeFactor = 1.0 - pow(2.0 * length(rayUv - 0.5), 2);
        float a = pow(min(1.0, (maxLength / 2) / length(ray)), 2.0) * saturate(edgeFactor);
        Color = Color * (1 - a) + SceneTextureLookup(bufferUv, 14, false).xyz * a;
        break;
    }
}
~~~

# 半透明マテリアルでの実装
コードを少し修正すれば半透明マテリアルでも流用できます。
本来は半透明マテリアルでSSRを使用する場合、比較的重いSurface Translucency VolumeかSurface Forward Shadingへ切り替える必要があります。
しかしCustomノードでSSRを実装してしまえば、一番軽いVolumetric Non Directionalで反射だけSSRを使うといった芸当が可能です。
## コード
~~~hlsl
int maxRayNum = 20;
float maxLength = 500;
float maxThickness = 500 / maxRayNum;

float2 uv = ViewportUVToBufferUV(GetViewportUV(Parameters));
float3 R = ReflectionAboutCustomWorldNormal(Parameters, Parameters.WorldNormal, false);
float3 step = maxLength / maxRayNum * R;

float3 pos = LWCToFloat(Parameters.AbsoluteWorldPosition);
float3 SpecularIBL = MaterialExpressionSkyLightEnvMapSample(R, 0);

for (int i = 1; i <= maxRayNum; i++) 
{
    float3 ray = step * (i + RandFast((uv + fmod(View.GameTime,1.0)) * View.ViewSizeAndInvSize.xy));
    float3 rayPos = pos + ray;
    float4 viewRayPos = mul(float4(rayPos, 1), LWCHackToFloat(PrimaryView.WorldToClip));
    float2 rayUv = viewRayPos.xy / viewRayPos.w * float2(0.5, -0.5) + 0.5;
    float2 bufferUv = ViewportUVToBufferUV(rayUv);
    float rayDepth = viewRayPos.w;
    float gbufferDepth = CalcSceneDepth(bufferUv);

    if (max(abs(rayUv.x - 0.5), abs(rayUv.y - 0.5)) > 0.5) break;
    
    if (rayDepth - gbufferDepth > 0 && rayDepth - gbufferDepth < maxThickness)
    {
        float edgeFactor = 1.0 - pow(2.0 * length(rayUv - 0.5), 2);
        float a = pow(min(1.0, (maxLength / 2) / length(ray)), 2.0) * saturate(edgeFactor);
        SpecularIBL = SpecularIBL * (1 - a) + DecodeSceneColorForMaterialNode(bufferUv) * a;
        break;
    }
}

return SpecularIBL;
~~~

## ポスプロ実装との差分
シーンテクスチャのサンプル方式が変わるだけです。
SceneTextureLookupはポスプロ専用なので、DecodeSceneColorForMaterialNode()を使用しています。
Dummyとして刺すノードもScene Colorノードに変更します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/149393/7c13dc1f-2f54-f933-3abc-b1c13a10fc22.png)
また、初期状態ではSkyLightをサンプルしています。
~~~hlsl
float3 SpecularIBL = MaterialExpressionSkyLightEnvMapSample(R, 0);
~~~
これによりレイが届かないスクリーン外の領域を空の色でカバーできます。
そのまま使おうとしてもコンパイルが通らないので、SkyAtmosphereCommon.ushをインクルードします。
![スクリーンショット 2023-06-07 192648.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/149393/e9e4132d-9f20-a4f3-d8de-c2a718d898f9.png)

# 応用
レイが飛ぶ方向をReflection Vectorではなく、Refraction Vectorにすることで精度の高い屈折表現も可能です。
屈折角はマテリアル関数のRefractで簡単に計算できます。
変数のRをパラメータとして外に出して使ってみました。
![スクリーンショット 2023-06-08 033751.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/149393/69d20e87-bdde-1d07-0e44-36f6e0a32e1f.png)
![refract.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/149393/467e463d-ae15-4f5c-cb45-97de87ab06f8.gif)

# 注意事項
バージョンが変わるとシェーダーコードの関数名が変わったりするので動かなくなる可能性があります。
PrimaryView.WorldToClipは割と最近この名前に変わったようです。

# 参考
Unity で Screen Space Reflection の実装をしてみた
https://tips.hecomi.com/entry/2016/04/04/022550