ShaderTips

シェーダーTips

主にUnityシェーダーについての記事を書いています。

【Unity】【URP】Litシェーダーのライティング処理を見る

UPRのLitシェーダーのライティング処理を一通り解説します。

ライティングの流れ

ライティングの流れは大まかに以下のようになっています。

最終ライトカラー値 = ①メインライト

+ ②追加ライト(ピクセル単位)

+ ③追加ライト(頂点単位)

+ ④大域照明ライト  

+ ⑤エミッションライト

メインライト

常にintensityが一番高いディレクショナルライトが割り当てられ、ピクセル単位で処理されます。 また、メインライトのみシャドウマップ使用時にカスケードシャドウを使用できます。

追加ライト

メインライトを除く、ディレクショナルライト、ポイントライト、スポットライトが設定したライトの最大数(上限8)まで割り当てられます。 設定した最大数を超えた分のライトは計算されません。

下の画像はライトの最大数を4個に設定して、ディレクショナルライト1個(メインライト)とポイントライトを5個配置しました。 上限が4個なので青色のポイントライトが無視されています。 どのライトが反映されるかはそのオブジェクトへの影響度で決まります。

今度は水色のポイントライトを床に当たらないように調整しました。 今度は青いポイントライトが反映されていますが、球体には水色のポイントライトが反映されており、合計5個のライトが反映されています。 これは1つのオブジェクトに反映される上限が4個だからです。

また、追加ライトは設定により頂点シェーダーかピクセルシェーダーのどちらでライティング処理を行うか選択ができます。

URPのライティングはURPアセットで設定できます。 ますは各設定項目について説明します。

ライティング処理を頂点シェーダーかフラグメントシェーダーのどちらで行うかURPアセットのAdditional Lightで設定できます。

Lit シェーダーのコードを見る

ここからは実際のシェーダーの中を追って、ライティング処理がどのように行われているか見ていきます。

頂点シェーダー

Varyings LitPassVertex(Attributes input)
{
    // 出力用構造体の初期化
    Varyings output = (Varyings)0;
   
    // インスンスIDの初期化
    UNITY_SETUP_INSTANCE_ID(input);
    // インスタンス ID を頂点シェーダの入力構造体から出力構造体にコピー
    UNITY_TRANSFER_INSTANCE_ID(input, output);
    //  立体視初期化
    UNITY_INITIALIZE_VERTEX_OUTPUT_STEREO(output);
    
    // 頂点座標変換
    VertexPositionInputs vertexInput = GetVertexPositionInputs(input.positionOS.xyz);

    // 法線ベクトル座標変換
    VertexNormalInputs normalInput = GetVertexNormalInputs(input.normalOS, input.tangentOS);
    // 視線ベクトル座標変換
    half3 viewDirWS = GetWorldSpa
    // 頂点シェーダーの追加ライトの計算
    half3 vertexLight = VertexLighting(vertexInput.positionWS, normalInput.normalWS);
    // フォグの強度の計算
    half fogFactor = ComputeFogFactor(vertexInput.positionCS.z);
    //  テクスチャのスケールオフセットを対応させたUV取得
    output.uv = TRANSFORM_TEX(input.texcoord, _BaseMap);

    output.normalWS = normalInput.normalWS;
    output.viewDirWS = viewDirWS;

#if defined(REQUIRES_WORLD_SPACE_TANGENT_INTERPOLATOR) || defined(REQUIRES_TANGENT_SPACE_VIEW_DIR_INTERPOLATOR)
    real sign = input.tangentOS.w * GetOddNegativeScale();
    half4 tangentWS = half4(normalInput.tangentWS.xyz, sign);
#endif
#if defined(REQUIRES_WORLD_SPACE_TANGENT_INTERPOLATOR)
    output.tangentWS = tangentWS;
#endif
    
#if defined(REQUIRES_TANGENT_SPACE_VIEW_DIR_INTERPOLATOR)
    half3 viewDirTS = GetViewDirectionTangentSpace(tangentWS, output.normalWS, viewDirWS);
    output.viewDirTS = viewDirTS;
#endif
    
    // ライトマップのUV
    OUTPUT_LIGHTMAP_UV(input.lightmapUV, unity_LightmapST, output.lightmapUV);
    // SHライトカラー
    OUTPUT_SH(output.normalWS.xyz, output.vertexSH);

    output.fogFactorAndVertexLight = half4(fogFactor, vertexLight);

#if defined(REQUIRES_WORLD_SPACE_POS_INTERPOLATOR)
    output.positionWS = vertexInput.positionWS;
#endif

#if defined(REQUIRES_VERTEX_SHADOW_COORD_INTERPOLATOR)
    output.shadowCoord = GetShadowCoord(vertexInput);
#endif

    output.positionCS = vertexInput.positionCS;

    return output;
}

頂点シェーダーの追加ライトの計算

頂点シェーダーで、追加ライトの計算を行なっている関数を見ていきます。

half3 VertexLighting(float3 positionWS, half3 normalWS)
{
    half3 vertexLightColor = half3(0.0, 0.0, 0.0);
// 追加ライトの計算を頂点シェーダーで行うとき有効
#ifdef _ADDITIONAL_LIGHTS_VERTEX
    // 計算対象とする追加ライトの数
    uint lightsCount = GetAdditionalLightsCount();
    // 計算対象の追加ライトをforで回しながら合算カラーを求める
    for (uint lightIndex = 0u; lightIndex < lightsCount; ++lightIndex)
    {
        // ライトのIndex(lightIndex)から対応するライト情報をLight構造体に格納
        Light light = GetAdditionalLight(lightIndex, positionWS);
        // 光の減衰
        half3 lightColor = light.color * light.distanceAttenuation;
        
        vertexLightColor += LightingLambert(lightColor, light.direction, normalWS);
    }
#endif

    return vertexLightColor;
}

_ADDITIONAL_LIGHTS_VERTEXはURPアセットのAdditional LightをPer Vertexにすると有効になります。 その下のfor文ではGetAdditionalLightsCountの数だけ、ライティング処理を行なっています。

int GetAdditionalLightsCount()
{
    return min(_AdditionalLightsCount.x, unity_LightData.y);
}

GetAdditionalLightsCountは計算対象とするライトの数を返しています。 これを以下の2つのパラメータの最小値をとることで求めています。

_AdditionalLightsCount.x・・・URPアセットのPer Object Limitの値が入ります。ただし、上限は8です。(グラフィックス APIOpenGL ES 2.0の場合は4)

unity_LightData.y・・・プリミティブに光が届いているライトの個数

Light GetAdditionalLight(uint i, float3 positionWS)
{
    int perObjectLightIndex = GetPerObjectLightIndex(i);
    return GetAdditionalPerObjectLight(perObjectLightIndex, positionWS);
}

GetAdditionalLightでは計算するライトのIndex(lightIndex)から対応するライト情報をLight構造体に格納します。

まずはforのIndexから対応する追加ライトのIndexを返します。 (影響度が高い順のライトのIndexから、純粋なライトのIndexを返す)

int GetPerObjectLightIndex(uint index)
{
//一部のプラットフォームではStructuredBuffersに問題があるため、このバリアントは無効になります。
// RenderingUtils.useStructuredBufferで必ずfalseを返しています。
#if USE_STRUCTURED_BUFFER_FOR_LIGHT_DATA
    uint offset = unity_LightData.x;
    return _AdditionalLightsIndices[offset + index];

// SHADER_API_GLESはOpenGL ES 2.0の時に有効になります。
// OpenGL ES 2.0ではライト上限が4個なので処理を分けています。
// unity_LightIndicesはfloat4 x 2の2次元配列でライト(最大8個)のインデックスが格納されています
#elif !defined(SHADER_API_GLES)
    return unity_LightIndices[index / 4][index % 4];
#else
    half2 lightIndex2 = (index < 2.0h) ? unity_LightIndices[0].xy : unity_LightIndices[0].zw;
    half i_rem = (index < 2.0h) ? index : index - 2.0h;
    return (i_rem < 1.0h) ? lightIndex2.x : lightIndex2.y;
#endif
}

ライトのインデックスから、Lgiht構造体に必要な情報を渡します。

// Fills a light struct given a perObjectLightIndex
Light GetAdditionalPerObjectLight(int perObjectLightIndex, float3 positionWS)
{
////一部のプラットフォームではStructuredBuffersに問題があるため、このバリアントは無効になります。
// RenderingUtils.useStructuredBufferで必ずfalseを返しています。
#if USE_STRUCTURED_BUFFER_FOR_LIGHT_DATA
    float4 lightPositionWS = _AdditionalLightsBuffer[perObjectLightIndex].position;
    half3 color = _AdditionalLightsBuffer[perObjectLightIndex].color.rgb;
    half4 distanceAndSpotAttenuation = _AdditionalLightsBuffer[perObjectLightIndex].attenuation;
    half4 spotDirection = _AdditionalLightsBuffer[perObjectLightIndex].spotDirection;
#else
    // ForwardLightsから渡された配列から追加ライトの情報を収集する。
    float4 lightPositionWS = _AdditionalLightsPosition[perObjectLightIndex];
    half3 color = _AdditionalLightsColor[perObjectLightIndex].rgb;
    half4 distanceAndSpotAttenuation = _AdditionalLightsAttenuation[perObjectLightIndex];
    half4 spotDirection = _AdditionalLightsSpotDir[perObjectLightIndex];
#endif

    // ライトベクトル
    //  LightPositionWS.wはディレクショナルライトの場合に0になります。
    float3 lightVector = lightPositionWS.xyz - positionWS * lightPositionWS.w;
    // 距離減衰と角度減衰をかける
    float distanceSqr = max(dot(lightVector, lightVector), HALF_MIN);
  
    half3 lightDirection = half3(lightVector * rsqrt(distanceSqr));
    half attenuation = DistanceAttenuation(distanceSqr, distanceAndSpotAttenuation.xy) * AngleAttenuation(spotDirection.xyz, lightDirection, distanceAndSpotAttenuation.zw);
    
    // 計算した情報をLight構造体に入れる
    Light light;
    light.direction = lightDirection;
    light.distanceAttenuation = attenuation;
    light.shadowAttenuation = 1.0;
    light.color = color;

    return light;
}

_AdditionalLightsXXXXから情報を受け取り、必要な計算をして、LIght構造体に入れています。 ここでは距離減衰と角度減衰について説明します。

距離減衰

スポットライトとポイントライトでは距離減衰を考慮します。 距離衰退とはライトとオブジェクトの距離が離れるほど、光が弱まることで、距離rの2乗に反比例して減衰します。

カスタムの減衰 - Unity マニュアル引用

DistanceAttenuation関数は距離減衰を考慮した光の強さを返します。

// UnityVanilaの減衰に一致します
// 減衰は光の範囲までスムーズに減少します。
float DistanceAttenuation(float distanceSqr, half2 distanceAttenuation)
{
    // rcp(distanceSqr)は、1/distanceSqrの近似です。
    // 近似なので1/distanceするより早いです。
    float lightAtten = rcp(distanceSqr);

#if SHADER_HINT_NICE_QUALITY
    half factor = distanceSqr * distanceAttenuation.x;
    half smoothFactor = saturate(1.0h - factor * factor);
    smoothFactor = smoothFactor * smoothFactor;
#else
    half smoothFactor = saturate(distanceSqr * distanceAttenuation.x + distanceAttenuation.y);
#endif

    return lightAtten * smoothFactor;
}

モバイルプラットフォームでは以下のような計算が行われています。

lightRange 光が届く範囲
distance 光が当たる物体までの距離

fadeDistance = 0.8 * lightRange

smoothFactor = \dfrac{lightRange^{2} - distance^{2}}{lightRange^{2} - fadeDinstance^{2}  }

この式で以下のような条件を満たしています。

・range が 80% までは光量は減衰しない(1以上になる)

・range が 80% から100% の間で線形に減衰する(1未満になる)

・range が 100% で光量がゼロになる

実際にはこの式を変形させて、CPUで事前計算した値をdistanceAttenuationに入れています コード上では以下のようにsaturateで0〜1の範囲にclampしています。

    half smoothFactor = saturate(distanceSqr * distanceAttenuation.x + distanceAttenuation.y);

角度減衰

スポットライトでは角度減衰も考慮します。

角度減衰は光が衰退を始める半影角度(画像のinner-corn)と、光が届かなくなる本影角度(画像のouter-corn)が存在します。

https://qiita-user-contents.imgix.net/https%3A%2F%2Fqiita-image-store.s3.amazonaws.com%2F0%2F10332%2Fa94ae34a-e635-1419-c6be-762b652d8d53.png?ixlib=rb-4.0.0&auto=format&gif-q=60&q=75&w=1400&fit=max&s=a9f48b00be240abbc392072c6c9a1ff7

スポットライトのライティングを実装する(4-8. 相当) - Qiitaより引用

half AngleAttenuation(half3 spotDirection, half3 lightDirection, half2 spotAttenuation)
{
    half SdotL = dot(spotDirection, lightDirection);
    half atten = saturate(SdotL * spotAttenuation.x + spotAttenuation.y);
    return atten * atten;
}
S スポットライトの方向ベクトル
L スポットライトから物体への方向ベクトル
cos\theta outer 本影角度
cos\theta outer 半影角度

AngleFactor=\left( \dfrac{\left( S\cdot L\right) -cos\theta outer}{\cos \theta inner-\cos \theta outer}\right) ^{2}

この式で以下のような条件を満たしています。

・半影角度までは光量は減衰しない

・半影角度から本影角度の間で線形に減衰する

・本影角度で光量がゼロになる

距離減衰と同じように実際にはこの式を変形させて、CPUで事前計算した値をdistanceAttenuationに入れています。

ランバートライティング

half3 VertexLighting(float3 positionWS, half3 normalWS)
{
    half3 vertexLightColor = half3(0.0, 0.0, 0.0);
// 追加ライトの計算を頂点シェーダーで行うとき有効
#ifdef _ADDITIONAL_LIGHTS_VERTEX
    // 計算対象とする追加ライトの数
    uint lightsCount = GetAdditionalLightsCount();
    // 計算対象の追加ライトをforで回しながら合算カラーを求める
    for (uint lightIndex = 0u; lightIndex < lightsCount; ++lightIndex)
    {
        // ライトのIndex(lightIndex)から対応するライト情報をLight構造体に格納
        Light light = GetAdditionalLight(lightIndex, positionWS);
     // 光の減衰
        half3 lightColor = light.color * light.distanceAttenuation;
        
        vertexLightColor += LightingLambert(lightColor, light.direction, normalWS);
    }
#endif

    return vertexLightColor;
}

GetAdditionalLightで必要なライト情報を格納したら、先ほど計算した光の減衰をライトカラーに乗算して、適応します。

その下のLightingLambertは法線とライトベクトルの内積をとって、ライトカラーに乗算しています

half3 LightingLambert(half3 lightColor, half3 lightDir, half3 normal)
{
    half NdotL = saturate(dot(normal, lightDir));
    return lightColor * NdotL;
}

頂点シェーダーでおこなう追加ライトのライティング処理はこれで終わりです。

グローバルイルミネーション

Autodesk Maya オンライン ヘルプ引用

続いて、グローバルイルミネーションの処理が行われます。 グローバルイルミネーションはGIや大域照明とも呼ばれます。 ライトから直接、当たる光(直接光)だけではなく、反射を繰り返した光(間接光)を考慮することで、上の画像のようにオブジェクトが互いの外観に影響を与えるので、よりリアルに見せることができます。

これを実現するにはリアルタイムで計算すると非常に高負荷なため、現実的ではありません。

Unityではライトマップまたはライトプローブでグローバルイルミネーションを低負荷で実装することができます。

ライトマップ

https://docs.unity3d.com/ja/2018.4/uploads/Main/Lightmapping-2.jpg ライトマッピング - はじめに - Unity マニュアル引用

ライトマップとは高負荷な光や影の計算をゲーム実行前に行い陰影情報を記録したテクスチャで、実行時にライトマップ専用のUVからライトマップを参照して、描画する技術です。 staticなオブジェクトやライトにしか適応できませんが、低負荷でリッチな描画ができます。 また、ライトマップ分のメモリを消費します。

ライトプローブ

https://docs.unity3d.com/ja/2018.4/uploads/Main/LightProbes-0.png ライトプローブ - Unity マニュアル 引用

ライトプローブではDytamicなオブジェクトに対しても、近い効果を得られます。 (光源が移動するものには使用できません) ライトプローブをシーン上に配置します。 ライトプローブにはその場所の色の情報を持っていおり、Dynamicなオブジェクトは周辺のライトプローブの色と位置関係から、自身に当たる光を計算します。

ライトマップUVまたはSHライトカラーの取得

VertexLightingの下を見ていくとライトマップとSHライトカラーのマクロが呼び出されています。

OUTPUT_LIGHTMAP_UV(input.lightmapUV, unity_LightmapST, output.lightmapUV);
OUTPUT_SH(output.normalWS.xyz, output.vertexSH);
#ifdef LIGHTMAP_ON
    #define DECLARE_LIGHTMAP_OR_SH(lmName, shName, index) float2 lmName : TEXCOORD##index
    #define OUTPUT_LIGHTMAP_UV(lightmapUV, lightmapScaleOffset, OUT) OUT.xy = lightmapUV.xy * lightmapScaleOffset.xy + lightmapScaleOffset.zw;
    #define OUTPUT_SH(normalWS, OUT)
#else
    #define DECLARE_LIGHTMAP_OR_SH(lmName, shName, index) half3 shName : TEXCOORD##index
    #define OUTPUT_LIGHTMAP_UV(lightmapUV, lightmapScaleOffset, OUT)
    #define OUTPUT_SH(normalWS, OUT) OUT.xyz = SampleSHVertex(normalWS)
#endif

ライトマップが設定されている場合は、ライトマップのUV座標を取得し、ライトマップが無効のときはライトプローブのSH(球面調和関数)ライトからライトカラー値を取得します。

これらの値をそれぞれ、output.lightmapUV、output.vertexSHに格納しています。

フラグメントシェーダー

half4 LitPassFragment(Varyings input) : SV_Target
{
    // インスンスIDの初期化
    UNITY_SETUP_INSTANCE_ID(input);
    // 立体視初対応
    UNITY_SETUP_STEREO_EYE_INDEX_POST_VERTEX(input);

#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
    ApplyPerPixelDisplacement(viewDirTS, input.uv);
#endif

    SurfaceData surfaceData;
    // テクスチャから必要なデータを取得
    InitializeStandardLitSurfaceData(input.uv, surfaceData);
    
    // 必要なデータを構造体にInputData格納
    InputData inputData;
    InitializeInputData(input, surfaceData.normalTS, inputData);
    
    // PBRの計算
    half4 color = UniversalFragmentPBR(inputData, surfaceData);
    
    // FOG適応
    color.rgb = MixFog(color.rgb, inputData.fogCoord);
    // アルファを適応
    color.a = OutputAlpha(color.a, _Surface);

    return color;
}

フラグメントシェーダーのライティング処理

フラグメントシェーダーのライティング処理はUniversalFragmentPBRに集約されています。 InitializeBRDFData、InitializeBRDFDataClearCoat、ではライティングに必要な情報の準備をしています。 その下ではshadowMaskの値を必要に応じて取得しています。 GetMainLightではライトの情報を取得しています。

half4 UniversalFragmentPBR(InputData inputData, SurfaceData surfaceData)
{
#ifdef _SPECULARHIGHLIGHTS_OFF
    bool specularHighlightsOff = true;
#else
    bool specularHighlightsOff = false;
#endif

    BRDFData brdfData;

    // BRDFdata構造体を構築
    InitializeBRDFData(surfaceData.albedo, surfaceData.metallic, surfaceData.specular, surfaceData.smoothness, surfaceData.alpha, brdfData);

    BRDFData brdfDataClearCoat = (BRDFData)0;
#if defined(_CLEARCOAT) || defined(_CLEARCOATMAP)
    // base brdfData is modified here, rely on the compiler to eliminate dead computation by InitializeBRDFData()
    InitializeBRDFDataClearCoat(surfaceData.clearCoatMask, surfaceData.clearCoatSmoothness, brdfData, brdfDataClearCoat);
#endif

    // ライトマップが有効でShadowMaskを使用している場合はinputData構造体に入れておいたShadowMaskの情報を格納
#if defined(SHADOWS_SHADOWMASK) && defined(LIGHTMAP_ON)
    half4 shadowMask = inputData.shadowMask;
    // ライトマップが無効な時
#elif !defined (LIGHTMAP_ON)
    half4 shadowMask = unity_ProbesOcclusion;
#else
    half4 shadowMask = half4(1, 1, 1, 1);
#endif
 // メインライトの情報を取得
    Light mainLight = GetMainLight(inputData.shadowCoord, inputData.positionWS, shadowMask);
     
    // SSAO
    #if defined(_SCREEN_SPACE_OCCLUSION)
        AmbientOcclusionFactor aoFactor = GetScreenSpaceAmbientOcclusion(inputData.normalizedScreenSpaceUV);
        mainLight.color *= aoFactor.directAmbientOcclusion;
        surfaceData.occlusion = min(surfaceData.occlusion, aoFactor.indirectAmbientOcclusion);
    #endif
    

    // 混合ライティング
    MixRealtimeAndBakedGI(mainLight, inputData.normalWS, inputData.bakedGI);
    // GI
    half3 color = GlobalIllumination(brdfData, brdfDataClearCoat, surfaceData.clearCoatMask,
                                     inputData.bakedGI, surfaceData.occlusion,
                                     inputData.normalWS, inputData.viewDirectionWS);
    color += LightingPhysicallyBased(brdfData, brdfDataClearCoat,
                                     mainLight,
                                     inputData.normalWS, inputData.viewDirectionWS,
                                     surfaceData.clearCoatMask, specularHighlightsOff);
    // 追加ライト
#ifdef _ADDITIONAL_LIGHTS
    uint pixelLightCount = GetAdditionalLightsCount();
    for (uint lightIndex = 0u; lightIndex < pixelLightCount; ++lightIndex)
    {
        Light light = GetAdditionalLight(lightIndex, inputData.positionWS, shadowMask);
        #if defined(_SCREEN_SPACE_OCCLUSION)
            light.color *= aoFactor.directAmbientOcclusion;
        #endif
        color += LightingPhysicallyBased(brdfData, brdfDataClearCoat,
                                         light,
                                         inputData.normalWS, inputData.viewDirectionWS,
                                         surfaceData.clearCoatMask, specularHighlightsOff);
    }
#endif

    // 追加のライトのライティング処理を頂点シェーダーで行った場合はそれを使う
    #ifdef _ADDITIONAL_LIGHTS_VERTEX
    color += inputData.vertexLighting * brdfData.diffuse;
#endif
    
    // 発光
    color += surfaceData.emission;

    return half4(color, surfaceData.alpha);
}

SSAO

更にその下にSSAOの処理があります。 処理を見る前にSSAOについて解説します。

SSAO(スクリーンスペース・アンビエント・オクルージョンの略称で、アンビエントオルクルージョン)は動的にAOを実行するポストエフェクトです。 AOとは光源や周辺の物体の間にある遮蔽物によって、どのくらい物体に届く直接光や間接光が減衰するかを表したものです。 下のSSAOありの画像では隣接するオブジェクトによって光が減衰し、影が入っていることが分かります。

SSAO なし

SSAO あり

AOを発生させるために、AOを発生させる位置とどのくらい濃く陰が出るかを表したSSAOテクスチャを使用します。

SSAOテクスチャを作成する方法はいくつかあり、URPのSSAOではAlchemy Ambient Obscuranceというアルゴリズムを採用しています。

http://cdn-ak.f.st-hatena.com/images/fotolife/A/Ambient-Occlusion/20131107/20131107145339.png スクリーンスペースアンビエントオクルージョンまとめ - アンビエントオクルージョンちゃん引用

SSAOテクスチャはスクリーンスペースで求めます。 注目しているピクセルPの法線方向を向いた半球の範囲内でサンプリングします。 Pと複数のサンプリングポイントのベクトルVと法線nから、内積をとり、足し合わせます。 内積はコサインなので値が大きくなるほど、P周辺の遮蔽度が高いということになります。 (Pが凹んでいる)

URPでSSAOを実装するにはRendererFeatureから、追加できます。

SSAOの処理を見ます。

// SSAO
    // SSAOが有効なとき
    #if defined(_SCREEN_SPACE_OCCLUSION)
        AmbientOcclusionFactor aoFactor = GetScreenSpaceAmbientOcclusion(inputData.normalizedScreenSpaceUV);
        mainLight.color *= aoFactor.directAmbientOcclusion;
        surfaceData.occlusion = min(surfaceData.occlusion, aoFactor.indirectAmbientOcclusion);
    #endif

_SCREEN_SPACE_OCCLUSIONはUniversalRenderPipeline.csのShaderKeywordStrings.ScreenSpaceOcclusionとして定義されており、 先ほど追加したRendererFeature(ScreenSpaceAmbientOcclusion)で、レンダーキューに追加される際にオンになります。

GetScreenSpaceAmbientOcclusion関数により、AOの衰退量を計算しています。 引数にはスクリーン上の座標を渡します。

AmbientOcclusionFactor GetScreenSpaceAmbientOcclusion(float2 normalizedScreenSpaceUV)
{
    AmbientOcclusionFactor aoFactor;
    aoFactor.indirectAmbientOcclusion = SampleAmbientOcclusion(normalizedScreenSpcaceUV);
    aoFactor.directAmbientOcclusion = lerp(1.0, aoFactor.indirectAmbientOcclusion, _AmbientOcclusionParam.w);
    return aoFactor;
}

SampleAmbientOcclusion関数によって、SSAOテクスチャから間接光の値をサンプリングします。

half SampleAmbientOcclusion(float2 normalizedScreenSpaceUV)
{
    float2 uv = UnityStereoTransformScreenSpaceTex(normalizedScreenSpaceUV);
    return SAMPLE_TEXTURE2D_X(_ScreenSpaceOcclusionTexture, sampler_ScreenSpaceOcclusionTexture, uv).x;
}

UnityStereoTransformScreenSpaceTex関数はVR用の関数なので、無視します。 重要なのはその下で、_ScreenSpaceOcclusionTextureからサンプリングして、その結果を返しています。

    aoFactor.directAmbientOcclusion = lerp(1.0, aoFactor.indirectAmbientOcclusion, _AmbientOcclusionParam.w);
    return aoFactor;

先ほどの関数に戻ります。

AmbientOcclusionParam.wはRendererFeatureのDirect Lighting Strengthで設定された値が入っています。 Direct Lighting Strengthはアンビエントオクルージョンに対する直接照明の影響を設定するので、先AmbientOcclusionParam.wの値でほどの間接光によるAOと1を補完しています。

その下では直接光のaoを決定しています。

    #if defined(_SCREEN_SPACE_OCCLUSION)
        AmbientOcclusionFactor aoFactor = GetScreenSpaceAmbientOcclusion(inputData.normalizedScreenSpaceUV);
        mainLight.color *= aoFactor.directAmbientOcclusion;
        surfaceData.occlusion = min(surfaceData.occlusion, aoFactor.indirectAmbientOcclusion);
    #endif

計算した直接光のAOはメインライトに乗算し、間接光のAOはsurfaceData.occlusionより小さかった場合はsurfaceData.occlusionに格納します。 surfaceData.occlusionはマテリアルで設定したオクルージョンマップのサンプリング結果が入っており、間接照明の影響を受けるかをマスクする役割があります。

オクルージョン マップ - Unity マニュアル

混合ライティング

次は混合ライティングの処理が行われます。

下の画像では赤色の動的オブジェクトと青色の静的オブジェクトの影が加算されて、濃くなってしまいます。

https://cdn-ak.f.st-hatena.com/images/fotolife/t/tsubaki_t1/20170418/20170418223021.jpg 【Unity】リアルタイムな影とベイクした影を混ぜる、Shadow Mask 特集号 - テラシュールブログ引用

混合ライティングとはライトマップやライトプローブなどの静的な影と動的な影を併用する際に、生合成を保つ手法です。

有効にするにはライトコンポーネントのModeをMixedにする必要があります。

混合ライティングには3種類あります。 順番に説明します。

Baked Indirect

Baked Indirect モード: 間接光だけが事前計算されます 混合ライティング - Unity マニュアル引用 

直接光はリアルタイムで計算され、間接光のみライトマップに焼かれます。

ただしShadow Distance (Edit > Project Settings > Quality > Shadows > Shadow Distance) の範囲外では影を作りません。 範囲外の部分をFogを使用することで隠すことができます。

Shadowmask

Shadowmask と Distance Shadowmask モード: 間接光と直接オクルージョンが事前計算されます

直接光の影と間接光の影をShadowMaskに書き込みます。 ShadowMaskはあくまでMaskなので動的に影色を変更することができます。

Distance ShadowMakを使用すると設定した距離まではリアルタイムで 計算し、それ以降はShadowMaskを使用します。

Subtractive

Subtractive モード: すべてのライトパスが事前計算されます とにかく軽くしたいときはSubtractiveにします。 LightmapとDirectional Lightmapが生成され、Directional ModeをNon-Directionalに変更するとLightmapのみが生成されます。

メインライトのみでリアルタイムに影をレンダリングし、その結果をベイクした直接光と間接光を合成します。 他のモードのいずれも使用できない場合使用します。

Subtractiveには、ライトマップの影にリアルタイムシャドウの影が二重に落ちてしまう弱点があります。Environment > Realtime Shadow Colorを調整して同じような(溶け込む)色にする必要があります。

void MixRealtimeAndBakedGI(inout Light light, half3 normalWS, inout half3 bakedGI)
{
#if defined(LIGHTMAP_ON) && defined(_MIXED_LIGHTING_SUBTRACTIVE)
    bakedGI = SubtractDirectMainLightFromLightmap(light, normalWS, bakedGI);
#endif
}

UniversalFragmentPBRで呼び出されているこの関数は ライトマップが有効で、Lighting Mode が Subtractiveが有効な時にSubtractDirectMainLightFromLightmapを呼び出します。

Shadowmask/Distance Shadowmaskモードは非対応で、今後対応予定です。

大照明ライトカラー

大照明ライトカラーは間接ディフューズライトカラーと間接スペキュラライトカラー値を乗算することで求まります。

half3 GlobalIllumination(BRDFData brdfData, BRDFData brdfDataClearCoat, float clearCoatMask,
    half3 bakedGI, half occlusion,
    half3 normalWS, half3 viewDirectionWS)
{
    // 反射ベクトル
    half3 reflectVector = reflect(-viewDirectionWS, normalWS);
    half NoV = saturate(dot(normalWS, viewDirectionWS));
       // フレネル項
    half fresnelTerm = Pow4(1.0 - NoV);
        // 間接ディフューズライトカラー値の算出
    half3 indirectDiffuse = bakedGI * occlusion;


    // 間接スペキュラライトカラー値の算出
    half3 indirectSpecular = GlossyEnvironmentReflection(reflectVector, brdfData.perceptualRoughness, occlusion);

    half3 color = EnvironmentBRDF(brdfData, indirectDiffuse, indirectSpecular, fresnelTerm);
   // クリアコートが有効な時
#if defined(_CLEARCOAT) || defined(_CLEARCOATMAP)
    half3 coatIndirectSpecular = GlossyEnvironmentReflection(reflectVector, brdfDataClearCoat.perceptualRoughness, occlusion);
    
    half3 coatColor = EnvironmentBRDFClearCoat(brdfDataClearCoat, clearCoatMask, coatIndirectSpecular, fresnelTerm);

    half coatFresnel = kDielectricSpec.x + kDielectricSpec.a * fresnelTerm;
    return color * (1.0 - coatFresnel * clearCoatMask) + coatColor;
#else
    return color;
#endif
}
    // 間接ディフューズライトカラー値の算出
    half3 indirectDiffuse = bakedGI * occlusion;

間接ディフューズライトカラー値は、ライトマップカラー 値(bakedGI)にオクルージョン量(occlusion)を乗算した値になります。

    // 間接スペキュラライトカラー値の算出
    half3 indirectSpecular = GlossyEnvironmentReflection(reflectVector, brdfData.perceptualRoughness, occlusion);

間接スペキュラライトカラーはGlossyEnvironmentReflectionで計算しています。

half3 GlossyEnvironmentReflection(half3 reflectVector, half perceptualRoughness, half occlusion)
{
// リフレクションプローブが有効な時
#if !defined(_ENVIRONMENTREFLECTIONS_OFF)
    half mip = PerceptualRoughnessToMipmapLevel(perceptualRoughness);
    half4 encodedIrradiance = SAMPLE_TEXTURECUBE_LOD(unity_SpecCube0, samplerunity_SpecCube0, reflectVector, mip);


#if defined(UNITY_USE_NATIVE_HDR) || defined(UNITY_DOTS_INSTANCING_ENABLED)
    half3 irradiance =  encodedIrradiance.rgb;
#else
    
    half3 irradiance = DecodeHDREnvironment(encodedIrradiance, unity_SpecCube0_HDR);
#endif

    return irradiance * occlusion;
#endif // GLOSSY_REFLECTIONS

    return _GlossyEnvironmentColor.rgb * occlusion;
}
half mip = PerceptualRoughnessToMipmapLevel(perceptualRoughness);

half4 encodedIrradiance = SAMPLE_TEXTURECUBE_LOD(unity_SpecCube0, samplerunity_
SpecCube0, reflectVector, mip);

PerceptualRoughnessToMipmapLevel 関数は、キューブマップからサンプリングする際のミップマップ レベル(mip)を計算します。 キューブマップからライトプローブカラーを取得しています。 PerceptualRoughnessToMipmapLevelの引数にperceptualRoughness(設定したラフネス量)にしているのは、ラフネス量が多くなるほど、mipmapレベルが上がり、風景の映り込みはぼやけていきます。

#if defined(UNITY_USE_NATIVE_HDR) || defined(UNITY_DOTS_INSTANCING_ENABLED)
    half3 irradiance =  encodedIrradiance.rgb;
#else
    
    half3 irradiance = DecodeHDREnvironment(encodedIrradiance, unity_SpecCube0_HDR);
#endif

ライトマップ系ではHDRレンダリングすることができます。 これを低ダイナミックレンジのテクスチャにエンコードできます。 それをDecodeHDREnvironment関数でデコードしたライトカラー値をイラジアンス(irradiance 放射照度)としています。

    return irradiance * occlusion;

反射マップからサンプリングした周辺光のライトカラー値(irradiance)に、オクルージョンマップからサンプリングしたオクルージョン量(occlusion)を乗算して戻り値とします。

EnvironmentBRDF関数は、EnvironmentBRDFClearCoat 環境ディヒューズカラー(indirectDiffuse)とマテリアルディヒューズカラー(brdfData.diffuse)を乗算し、環境スペキュラーカラー(indirectSpecular)とマテリアルスペキュラカラー(EnvironmentBRDFSpecular関数)を乗算した値を加算しています。

half3 EnvironmentBRDF(BRDFData brdfData, half3 indirectDiffuse, half3 indirectSpecular, half fresnelTerm)
{
    half3 c = indirectDiffuse * brdfData.diffuse;
    c += indirectSpecular * EnvironmentBRDFSpecular(brdfData, fresnelTerm);
    return c;
}

EnvironmentBRDFSpecular関数ではマテリアルスペキュラカラーを計算します・

half3 EnvironmentBRDFSpecular(BRDFData brdfData, half fresnelTerm)
{
    float surfaceReduction = 1.0 / (brdfData.roughness2 + 1.0);
    return surfaceReduction * lerp(brdfData.specular, brdfData.grazingTerm, fresnelTerm);
}
 float surfaceReduction = 1.0 / (brdfData.roughness2 + 1.0);

間接光の幾何減衰を簡易的に算出します。

    return surfaceReduction * lerp(brdfData.specular, brdfData.grazingTerm, fresnelTerm);

lerpの部分ではフレネル反射率を表しています。

【Unity】【シェーダー】 フレネル反射 - シェーダーTips

メインライトのライトカラー

half3 LightingPhysicallyBased(BRDFData brdfData, BRDFData brdfDataClearCoat,
    half3 lightColor, half3 lightDirectionWS, half lightAttenuation,
    half3 normalWS, half3 viewDirectionWS,
    half clearCoatMask, bool specularHighlightsOff)
{
    // ラジアンス(放射輝度) 
    half NdotL = saturate(dot(normalWS, lightDirectionWS));
    half3 radiance = lightColor * (lightAttenuation * NdotL);

// 5-2:BRDF項を計算し、最終的なライトカラー値を算出する。 return 
   return DirectBDRF(brdfData,normalWS,light.direction, viewDirectionWS) * radiance;
}
    half NdotL = saturate(dot(normalWS, lightDirectionWS));

ランバートの計算を行なっています。

    half3 radiance = lightColor * (lightAttenuation * NdotL);

ライトの距離と角度減衰(light.distanceAttenuation) と影減衰(light.shadowAttenuation)を乗算して最終的な減衰値(lightAttenuation)ととランバート(NdotL)とライト自体に設定されたライトカラー値(lightColor)を乗算し、ラジアンスとします。

スペキュラBRDF(双方向反射率分布関数)

DirectBDRFでは直接スペキュラライトカラー値の反射率を求めるために、BRDFを使用します。

LitシェーダーのBRDF式はUnityがCook-Torranceモデルの簡易版をベースに開発したMinimalist CookTorrance BRDFが採用されています。

記号は以下です。

D項 法線分布関数
G項 幾何減衰
F項 フレネル
N 法線ベクトル
V 視点方向ベクトル
L ライト方向ベクト ル
roughness ラフネス
specColor マテリアルスペキュラカラー

D項、G項、F項については以下のサイトでそれぞれの解説しているので、必要であればご覧ください。

D項 ny-program.hatenablog.com

G項 ny-program.hatenablog.com

F項 ny-program.hatenablog.com

Cook-Torranceモデルの式は以下になります。

B R D F s p e c = D · V · F 4 ( N · V ) · ( N · L )

ここで G ( N · V ) · ( N · L ) をV項(Visibility term 可視度項)とし、式を変更します。

B R D F s p e c = D · V · F 4

D項にGGXモデル、V項にKSK近似モデル、F項にChristian Schüler による近似式を使います。

G G X = r o u g h n e s s 2 π · ( ( N · H ) 2 r o u g ha s s 2 - 1 ) + 1 ) 2

V s k s = 1 ( L · H ) ( L · H )

F = s p e i c o l o r ( L · H )

roughness は表面のざらつきを表すパラメータです。specColorはマテリアルスペキュラカラー値です。Hはハーフベクトルです。 これらの項の中で、V項とF項に採用した近似式は、どちらもかなり大胆な近似で、高速ですが、物理的な正しさは不十分です。。そこで、V項とF項を1 つの式とし、さらにパラメータを追加して以下のように改良します。

V F = 1.0 ( L · H ) 2 · ( r o u y h n e s s + 0.5 ) · s p e c C o l o r

この式は、モバイルでも高速に動作し、物理的な再現性が比較的高い式になっているようです。 これらのG項とV・F項を元の式にあてはめると、最終的に以下の式になります。また、specColorは最終的にカラー値を算出する時に乗算するので、BRDF式では以後記述を省略します。

B R D F s p e c = r o u g h n e s s 2 π · ( ( N · H ) 2 · ( r o u g h n e s s - 1 ) + 1 2 ) · ( ( L · H ) 2 · ( r o u g h n e s s + 0.5 ) · 4.0 )

この式が DirectBDRF関数内で評価され、スペキュラ反射率を算出します。ただし、式の中のいくつかの項は事前に計算され、BRDFData構造体にあらかじめ格納されます。 BRDFData構造体の要素名を以下に示します。

roughness 見た目上のラフネス量
roughness2 roughnessの2乗
roughness2MinusOne roughness2-1
normalizationTerm roughness * 4.0h + 2.0h

half3 DirectBDRF(BRDFData brdfData, half3 normalWS,
half3 lightDirectionWS, half3 viewDirectionWS)
{
// マテリアルインスペクタのAdvanced/Specular HighLithgtsをオンにすると、鏡面光沢が反映されるようになります。
#ifndef _SPECULARHIGHLIGHTS_OFF

float3 halfDir = SafeNormalize( float3(lightDirectionWS) +
float3(viewDirectionWS)); 
float NoH = saturate(dot(normalWS, halfDir));
half LoH = saturate(dot(lightDirectionWS, halfDir));
float d = NoH * NoH * brdfData.roughness2MinusOne + 1.00001f; half LoH2 = LoH * LoH;
half specularTerm = brdfData.roughness2 / ((d * d) *
max(0.1h, LoH2) * brdfData.normalizationTerm);

#if defined (SHADER_API_MOBILE) || defined (SHADER_API_SWITCH)
specularTerm = specularTerm - HALF_MIN;
specularTerm = clamp(specularTerm, 0.0, 100.0); #endif
half3 color = specularTerm * brdfData.specular + brdfData.diffuse; // D
return color; #else
return brdfData.diffuse; // E #endif
}
// ハーフベクトル
float3 halfDir = SafeNormalize( float3(lightDirectionWS) +
float3(viewDirectionWS)); 
// ハーフベクトルと法線の内積 
float NoH = saturate(dot(normalWS, halfDir));
// ハーフベクトルとライトベクトルの内積
half LoH = saturate(dot(lightDirectionWS, halfDir));
float d = NoH * NoH * brdfData.roughness2MinusOne + 1.00001f; 
half LoH2 = LoH * LoH;
half specularTerm = brdfData.roughness2 / ((d * d) *
max(0.1h, LoH2) * brdfData.normalizationTerm);

ここでスペキュラBRDFの式を計算しています。 変数dの計算時に1.0ではなく1.00001を加算しているのは後の式でゼロ除算を回避するためです。

#if defined (SHADER_API_MOBILE) || defined (SHADER_API_SWITCH)
specularTerm = specularTerm - HALF_MIN;
specularTerm = clamp(specularTerm, 0.0, 100.0); #endif
half3 color = specularTerm * brdfData.specular + brdfData.diffuse;  // D
return color; 
#else
return brdfData.diffuse; // E #endif

マテリアルスペキュラカラー値(brdfData.specular)に、BRDF式で計算した反射率 (specularTerm)を乗算し、それにマテリアルディフューズカラー値(brdfData.diffuse)を加算した値を、メインライトの反射による直接ライトカラー値とします。

追加ライト&発光

UniversalFragmentPBRの残りの処理を見ていきます。

half4 UniversalFragmentPBR(InputData inputData, SurfaceData surfaceData)
{
・
・
・
省略
    // 追加ライト
#ifdef _ADDITIONAL_LIGHTS
    uint pixelLightCount = GetAdditionalLightsCount();
    for (uint lightIndex = 0u; lightIndex < pixelLightCount; ++lightIndex)
    {
        Light light = GetAdditionalLight(lightIndex, inputData.positionWS, shadowMask);
        #if defined(_SCREEN_SPACE_OCCLUSION)
            light.color *= aoFactor.directAmbientOcclusion;
        #endif
        color += LightingPhysicallyBased(brdfData, brdfDataClearCoat,
                                         light,
                                         inputData.normalWS, inputData.viewDirectionWS,
                                         surfaceData.clearCoatMask, specularHighlightsOff);
    }
#endif

    // 追加のライトのライティング処理を頂点シェーダーで行った場合はそれを使う
    #ifdef _ADDITIONAL_LIGHTS_VERTEX
    color += inputData.vertexLighting * brdfData.diffuse;
#endif
    
    // 発光
    color += surfaceData.emission;

    return half4(color, surfaceData.alpha);
}

追加ライトを頂点シェーダーで計算してた場合(_ADDITIONAL_LIGHTS_VERTEXが有効)はその値を使用します。 そうでなければフラグメントシェーダーで処理します。

color += surfaceData.emission;

HDRカラーを加算して発光表現を行います。 詳しくはこちらをご参考ください。

ny-program.hatenablog.com

以上でLitシェーダーのライティング処理について一通り解説し終えました。 最後までご覧いただきありがとうございました。

参考

light11.hatenadiary.com

docs.unity3d.com

denx.hatenablog.jp

zenn.dev

https://casual-effects.com/research/McGuire2011AlchemyAO/VV11AlchemyAO.pdf

hikita12312.hatenablog.com