【Unity】Litシェーダを改造して3DノイズでProceduralなBump, Parallaxを実現したい【UniversalRP】

1. 環境

Unity 2020.3.18

UniversalRP 10.6.0

2. やりたいこと

UniversalRP LitシェーダではマテリアルインスペクタのNormalMapにBumpマップ、HeightMapにParallaxマップを設定すれば表面上の凸凹をdisplacementなしで表現できます。

しかし、今回は勉強も兼ねてLitシェーダを改造してテクスチャを使用することなく、物体の表面の凸凹を3DノイズでProceduralに再現出来ないかと思い色々試してみました。

f:id:SleepingGaming:20211005175259g:plain:w320

3. UniversalRP LitシェーダのBump, Parallax

まず、LitシェーダがBumpマップとParallaxマップからどのように凸凹を表現しているかを見てみます。

例として、PerlinNoiseテクスチャから作成したBumpマップとParallaxマップを設定したLitシェーダマテリアルはCubeだと下の画像のような表現になります。

f:id:SleepingGaming:20211005185208p:plain:w320

表面の凸凹に対するシェーディングやライティングが表現されているのが分かります。

さて、設定したBumpマップとParallaxマップがLitシェーダ内のどの部分でどのように処理されているのか見てみます。

LitシェーダのFragmentシェーダは以下のようになっています。

half4 LitPassFragment(Varyings input) : SV_Target
{
    ~

#if defined(_PARALLAXMAP)
#if defined(REQUIRES_TANGENT_SPACE_VIEW_DIR_INTERPOLATOR)
    half3 viewDirTS = input.viewDirTS;
#else
    half3 viewDirTS = GetViewDirectionTangentSpace(input.tangentWS, input.normalWS, input.viewDirWS);
#endif
    // ↓Parallaxマップからサンプリングした値でuvを視差分ずらす
    ApplyPerPixelDisplacement(viewDirTS, input.uv);
#endif

    SurfaceData surfaceData;
    // ↓視差分ずれたuvを用いてBumpマップから接空間法線ベクトル(normalTS)を求めsurfaceDataに格納
    InitializeStandardLitSurfaceData(input.uv, surfaceData);

    InputData inputData;
    // ↓normalTSからワールド空間法線ベクトル(normalWS)を求めinputDataに格納
    InitializeInputData(input, surfaceData.normalTS, inputData);

    // ↓inputDataのnormalWSを用いてGIやMainLight、追加Lightのライティング計算
    half4 color = UniversalFragmentPBR(inputData, surfaceData);

    ~

    return color;
}

コメントアウトで記載した部分がBump, Parallaxを用いた法線処理です。一つずつ見ていきます。

3-1. ApplyPerPixelDisplacement

このメソッドはLitInput.hlslに実装されています。

void ApplyPerPixelDisplacement(half3 viewDirTS, inout float2 uv)
{
#if defined(_PARALLAXMAP)
    uv += ParallaxMapping(TEXTURE2D_ARGS(_ParallaxMap, sampler_ParallaxMap), viewDirTS, _Parallax, uv);
#endif
}

ParallaxMappingメソッドはParallaxMapping.hlslに実装されています。

half2 ParallaxOffset1Step(half height, half amplitude, half3 viewDirTS)
{
    height = height * amplitude - amplitude / 2.0;
    half3 v = normalize(viewDirTS);
    v.z += 0.42;
    return height * (v.xy / v.z);
}

float2 ParallaxMapping(TEXTURE2D_PARAM(heightMap, sampler_heightMap), half3 viewDirTS, half scale, float2 uv)
{
    half h = SAMPLE_TEXTURE2D(heightMap, sampler_heightMap, uv).g;
    float2 offset = ParallaxOffset1Step(h, scale, viewDirTS);
    return offset;
}

ParallaxMappingでは、height(Parallaxマップからサンプリングした値)とscale(_Parallaxプロパティ)とviewDirTS(接空間視線ベクトル)を用いてParallaxOffset1Stepで求めたoffsetをuvに追加しています。

ここでの処理について詳しく見ていきます。

まず、ParallaxマップはBumpマップ(陰影変化)では表現しきれない「凹凸による視点との距離の変化(視差効果)」を表現してより凸凹をリアルに感じさせるためのマップです。

f:id:SleepingGaming:20211002190417p:plain:w400

例えば上の図のように表面の凹みを表現したいとき、本来ピクセルが存在する場所より遠くにあるように見せられれば効果的です。

そのためにピクセルのuv座標を少し遠くにずらします。

ParallaxOffset1Stepでは、凹凸の高さに対して視線ベクトルを用いてuvをどれだけずらすかを求めています(上図の青いベクトル)。 このベクトル接空間ベクトルなのでuvに直接追加しています。

3-2. InitializeStandardLitSurfaceData

このメソッドはLitInput.hlslに実装されています。

inline void InitializeStandardLitSurfaceData(float2 uv, out SurfaceData outSurfaceData)
{
    ~
    outSurfaceData.normalTS = SampleNormal(uv, TEXTURE2D_ARGS(_BumpMap, sampler_BumpMap), _BumpScale);
    ~
}

ここで先ほどParallaxマップによってずらしたuvを用いてBumpマップから接空間法線ベクトルをサンプリングしています。

3-3. InitializeInputData

ここでは詳しく記載しませんが、Bumpマップを設定している場合先ほどサンプリングした接空間法線ベクトルからワールド空間法線ベクトルを求めinputDataに格納します。

3-4. UniversalFragmentPBR

先ほどワールド空間法線ベクトルを格納したinputDataを用いてGIやMainLight、追加Lightのライティング計算を行います。

4.3Dノイズで凸凹を表現する

上記のFragmentシェーダの流れから、3-1, 2で行われている内容をテクスチャの代わりにノイズを用いて実現出来れば目的が達成されますが、 今回はuvを用いた2Dノイズではなくオブジェクト空間座標を用いた3Dノイズを使用したいのでまずはその変更が必要です。

頂点シェーダからフラグメントシェーダにオブジェクト空間座標を渡します。

struct Varyings
{
    float2 uv : TEXCOORD0;
    ~
    // 追加
    float3 positionOS : POS;
    float3 normalOS : NORMAL;
    float4 tangentOS : TANGENTl;
    ~
}

// 頂点シェーダ
Varyings LitPassVertex(Attributes input)
{
    Varyings output = (Varyings) 0;
    ~
    // 追加
    output.positionOS = input.positionOS.xyz;
    output.normalOS = input.normalOS;
    output.tangentOS = input.tangentOS;
    ~
}

これでフラグメントシェーダで各フラグメントのpositionOS, normalOS, tangentOSが取得できるようになりました(normalOSとtangentOSは後ほど使用します)。

次に3-1の部分(Parallax)をテクスチャではなくノイズを用いたかたちに変更したいのですが、まず先に3-2の部分(Bump)を変更します。

4-1. normalTSを3Dノイズから求める

新たにposNormというメソッドを作成し、フラグメントシェーダの一部を書き換えます。

float3 posNorm(float3 pos, float3 normalOS, float4 tangentOS, half3 viewDirTS)
{
    float sgn = tangentOS.w;
    float3 bitangentOS = sgn * cross(normalOS, tangentOS.xyz);
    
    float me = perlinNoise(pos);
    float n = perlinNoise(pos + bitangentOS / 100.0);
    float s = perlinNoise(pos - bitangentOS / 100.0);
    float e = perlinNoise(pos - tangentOS.xyz / 100.0);
    float w = perlinNoise(pos + tangentOS.xyz / 100.0);
 
    float3 norm = float3(0.0, 0.0, 1.0);
    float3 temp = norm;
    temp.x += 0.5;
 
    float3 perp1 = normalize(cross(norm, temp));
    float3 perp2 = normalize(cross(norm, perp1));
 
    float3 normalOffset = -1.0 * (((n - me) - (s - me)) * perp1 + ((e - me) - (w - me)) * perp2);
    
    return normalize(norm + _BumpScale * normalOffset);
}

half4 LitPassFragment(Varyings input) : SV_Target
{
    ~
    SurfaceData surfaceData;
    surfaceData.normalTS = posNorm(input.positionOS, input.normalOS, input.tangentOS, viewDirTS);
    // ※InitializeStandardLitSurfaceDataメソッドのsurfaceData引数をoutからinoutへ変更する必要あり
    InitializeStandardLitSurfaceData(input.uv, surfaceData);
    ~
}

posNormメソッドについて見ていきます。

    float me = perlinNoise(pos);
    float n = perlinNoise(pos + bitangentOS / 100.0);
    float s = perlinNoise(pos - bitangentOS / 100.0);
    float e = perlinNoise(pos - tangentOS.xyz / 100.0);
    float w = perlinNoise(pos + tangentOS.xyz / 100.0);

3Dノイズの値はオブジェクトの表面上からサンプリングするため、tangentOS, bitangentOS方向に僅かにずらした位置のノイズ値を取得します。

    float3 norm = float3(0.0, 0.0, 1.0);
    float3 temp = norm;
    temp.x += 0.5;
 
    float3 perp1 = normalize(cross(norm, temp));
    float3 perp2 = normalize(cross(norm, perp1));
 
    float3 normalOffset = -1.0 * (((n - me) - (s - me)) * perp1 + ((e - me) - (w - me)) * perp2);

perp1, perp2はそれぞれ接空間におけるbitangent方向とtangent方向です。 perp1には先ほど求めたbitangentOS方向の増分を乗算し、perp2にはtangentOS方向の増分を乗算してそれらを加算します。

    return normalize(norm + _BumpScale * normalOffset);

normalOffsetにnorm(接空間法線ベクトル)を追加し、正規化したものをnormalTSとして返します。

f:id:SleepingGaming:20211005193650p:plain:w320

これで3Dノイズによる陰影表現が出来ました。

次にParallaxマップによる視差効果をノイズを用いたかたちに変更します。

4-2. 3Dノイズで視差効果を生み出す

LitシェーダではApplyPerPixelDisplacementでuvをずらすことで視差効果を生み出していますが、 今回は3Dノイズを使用しているためオブジェクト空間座標をずらす必要があります。

先ほどのposNormメソッドに追記します。

float3 posNorm(float3 pos, float3 normalOS, float4 tangentOS, half3 viewDirTS)
{
    float sgn = tangentOS.w;
    float3 bitangentOS = sgn * cross(normalOS, tangentOS.xyz);
    
    // 追加
    half2 offset = ParallaxOffset1Step((1.0 + perlinNoise(pos)) * 0.5, _ParallaxScale, viewDirTS);
    pos -= (offset.x * normalize(tangentOS.xyz) + offset.y * normalize(bitangentOS)) * 0.04;
    
    float me = perlinNoise(pos);
    ~
}

ParallaxOffset1Stepは3-1と同様のメソッドです。

    pos -= (offset.x * normalize(tangentOS.xyz) + offset.y * normalize(bitangentOS)) * 0.04;

ParallaxOffset1Stepで接空間におけるoffsetを求めたあと、offset.x(接空間tangent方向のoffset)にtangentOSを、offset.y(接空間bitangent方向のoffset)にbitangentOSをそれぞれ乗算して加算し、それをオブジェクト空間位置座標のoffsetとしてposから減算しています。

f:id:SleepingGaming:20211005224917g:plain:w320

視差効果が反映されているのが分かります。

これで、BumpマップやParallaxマップを使わずにLitシェーダで3Dノイズを用いてProceduralにBump, Parallaxを実現することが出来ました。

参考にさせて頂いたリンク・書籍

Unityシェーダープログラミングの教科書4 SRP[1]URP/Litシェーダー解説編:染井吉野ゲームズ

その5 0から学ぶ法線マップ

床井研究室 - 第8回 視差マッピング

creating normals from alpha/heightmap inside a shader? — polycount