Unity で Standard Surface Shader の変換後のコードを追ってみた (Forward) - 凹みTips

凹みTips

C++、JavaScript、Unity、ガジェット等の Tips について雑多に書いています。

Unity で Standard Surface Shader の変換後のコードを追ってみた (Forward)

はじめに

サーフェスシェーダUnity が提供してくれているライティングなどの処理を簡略化して書けるシェーダです。予め定義された SurfaceOutputSurfaceOutputStandard といった構造体に必要な情報を詰め、影を使うかアルファを使うかといったオプションを選択してあげると、それらの情報を元に頂点・フラグメントシェーダに変換してくれます。

docs.unity3d.com

変換後の頂点・フラグメントシェーダはサーフェスシェーダ時と比べるとかなり長いコードになります(Standard Shader よりは少しシンプルです)。パスも複数含まれ、Forward Base(Forward レンダリング時のベース処理)、Forward Add(Forward レンダリング時のライト加算処理)、Deferred(Deferred レンダリング時の処理)、Meta(ライトマップ用:こちらを参照)といったものが自動的に作成されます。

本エントリでは、Forward のパス(ベースパスと加算パス)について深掘りしてみようと思います。Deferred パスについては以前に変換後のコード解説は書きましたので、そちらをご参照下さい:

tips.hecomi.com

Forward レンダリングの描画について

変換後のコードを見ていく前に Unity の Forward レンダリング自体について見ていきます。意外と細かく見ている人は少ないのではないでしょうか(私も今回始めて真面目に読みました)。

docs.unity3d.com

Forward の関連しているパスは 2 つ、Forward Base(ベースパス)と Forward Add(加算パス)があり、両方でライティングの反映がされます。ベースパスは毎回実行され、メインのディレクショナルライトに加え、球面調和ライティング、静的なライトマップ、リアルタイム GI、リフレクションプローブといった影響の計算を行います。

加算パスは追加のライトの計算を行うのですが、この「追加の」とは何を指すのか、これを知るには Unity の Forward レンダリングにおけるライトの扱いについて理解する必要があります。次の図を見て下さい(ドキュメントから抜粋):

f:id:hecomi:20180513234131p:plain  f:id:hecomi:20180513234137p:plain

A ~ H の 8 個のライト(ここではポイントライトとする)があることを想定します。これらのライトの設定(距離と強度)は同一で A ~ H 順に近いとします。また、ライトの Render ModeAuto に設定されているものとします(Not ImportantImportant が他にあります)。つまり、A から順に強度の強いライトになり、オブジェクトから見るとこの順に重要なライトとなるわけです(Not ImportantImportant を選ぶと重要度を調整可能)。

まず、A ~ D の 4 つのライトは重要度が高く正確なライティングの反映が必要なことから、ピクセル単位で計算されることになっています。次に D だけオーバーラップして、D ~ G までのライトは頂点単位で計算(= 頂点シェーダで処理)されます。そしてまたオーバーラップして G と残りの H のライトは球面調和ライティング(球面調和関数(Spherical Harmonics: SH)という関数を使って色々な方向からの光を少数のパラメタに圧縮する方法)で計算されます。順に再現性が落ちていくのですが、オブジェクトまたはライトが動いて位置関係が変化した場合、この重要度順が切り替わることによって急激なライトの変化が生じてしまうのを防ぐために 1 つだけオーバーラップさせています。なお、ピクセル単位で計算されるライトの数は「Project Settings > Quality > Rendering > PIxel Light Count」から変えることができ、デフォルトでは「4」になっているため、A ~ D がピクセル単位で計算されます(2 にしたら A と B になります)。

つまり、Pixel Light Count で指定した数までのライト、ここでは A ~ D のライトが、ピクセル単位で処理される = 加算パスで描画されることになります。そしてそれ以上ライトがあった場合は、最大 4 つまで(ここでは D ~ G)、ベースパスの頂点シェーダで処理されます(4 つまでという数は設定項目はなく、Unity によって決められた数です)。更にライトがあった場合(ここでは G、H)加えて残りの 2 つは SH ライティングがなされ、この処理はベースパスの頂点およびフラグメント両方で行われます。

この挙動を具体的に Frame Debugger で見ていきましょう。次のようなシーンを用意します。

f:id:hecomi:20180515011346p:plain

段々と距離を離して放射状にポイントライトが設置されています。色は分かりやすいように変えています。この上でどんなパスを経由して画が作られているか、次の動画をご覧ください:

まず、ベースパスの描画時点でうっすらと色が乗っているのが分かると思います。これがベースパスの頂点シェーダ内で頂点ライト及び SH ライティングが反映されている結果です。次に 4 つの加算パスが実行されています。最も近い赤ではなく緑から描画されていることから、比視感度みたいなものも考慮してソートされているのかなと推測されます。また、ディレクショナルライトを ON にした場合は、こちらはベースパスで処理されているのが分かると思います。なお、ライトの数を増やしても加算パスは増えず、遠くにあったり強度の弱い重要でないものはベースパスで処理されます。

また、ライトマップやライトプローブについてもついでに見ておきましょう。次の動画をご覧ください:

両者ともにベースパスで処理されているのがわかります。以上より、追加のライトのうち重要なもの(強度が大きいものから順に Pixel Light Count で設定した数まで)が加算パスで処理され、その他のライティング全般はベースパスで処理されることが分かりました。

ベースパス

ではこれらの処理をシェーダで見てきましょう。ここでは Standard Surface Shader を Show generated code したものを対象にします。シェーダは複雑に見えますが、マクロや条件分岐で難しく見えてるところが多く、一つ一つの理論・数式を追わなければ何をやってるかの理解はそこまで難しい作業ではないと思います(長いですが)。なお、シェーダは読みやすいよう適当に並べ替えたり削除したりとそこそこ手を加えています*1。最初はベースパスから見ていきましょう。

全文

ちょっと長いですが、まずは全文を貼ります。

Pass
{

Name "FORWARD"
Tags { "LightMode" = "ForwardBase" }

CGPROGRAM
#pragma vertex vert_surf
#pragma fragment frag_surf
#pragma target 3.0
#pragma multi_compile_instancing
#pragma multi_compile_fog
#pragma multi_compile_fwdbase
#include "HLSLSupport.cginc"
#include "UnityShaderVariables.cginc"
#include "UnityShaderUtilities.cginc"

#define UNITY_PASS_FORWARDBASE
#include "UnityCG.cginc"
#include "Lighting.cginc"
#include "UnityPBSLighting.cginc"
#include "AutoLight.cginc"

sampler2D _MainTex;
float4 _MainTex_ST;
half _Glossiness;
half _Metallic;
fixed4 _Color;

struct Input
{
    float2 uv_MainTex;
};

void surf(Input IN, inout SurfaceOutputStandard o)
{
    fixed4 c = tex2D(_MainTex, IN.uv_MainTex) * _Color;
    o.Albedo = c.rgb;
    o.Metallic = _Metallic;
    o.Smoothness = _Glossiness;
    o.Alpha = c.a;
}

struct v2f_surf
{
    UNITY_POSITION(pos);
    float2 pack0 : TEXCOORD0;
    float3 worldNormal : TEXCOORD1;
    float3 worldPos : TEXCOORD2;
    float4 lmap : TEXCOORD3;
    UNITY_SHADOW_COORDS(4)
    UNITY_FOG_COORDS(5)
#ifndef LIGHTMAP_ON
    #if UNITY_SHOULD_SAMPLE_SH
    half3 sh : TEXCOORD6;
    #endif
#endif
    UNITY_VERTEX_INPUT_INSTANCE_ID
    UNITY_VERTEX_OUTPUT_STEREO
};

v2f_surf vert_surf(appdata_full v)
{
    v2f_surf o;
    UNITY_INITIALIZE_OUTPUT(v2f_surf, o);

    UNITY_SETUP_INSTANCE_ID(v);
    UNITY_TRANSFER_INSTANCE_ID(v, o);
    UNITY_INITIALIZE_VERTEX_OUTPUT_STEREO(o);

    o.pos = UnityObjectToClipPos(v.vertex);
    o.pack0.xy = TRANSFORM_TEX(v.texcoord, _MainTex);
    float3 worldPos = mul(unity_ObjectToWorld, v.vertex).xyz;
    float3 worldNormal = UnityObjectToWorldNormal(v.normal);
    o.worldPos = worldPos;
    o.worldNormal = worldNormal;

#ifdef DYNAMICLIGHTMAP_ON
    o.lmap.zw = v.texcoord2.xy * unity_DynamicLightmapST.xy + unity_DynamicLightmapST.zw;
#endif

#ifdef LIGHTMAP_ON
    o.lmap.xy = v.texcoord1.xy * unity_LightmapST.xy + unity_LightmapST.zw;
#endif

#ifndef LIGHTMAP_ON
    #if UNITY_SHOULD_SAMPLE_SH && !UNITY_SAMPLE_FULL_SH_PER_PIXEL
        o.sh = 0;
        #ifdef VERTEXLIGHT_ON
            o.sh += Shade4PointLights(
                unity_4LightPosX0, 
                unity_4LightPosY0, 
                unity_4LightPosZ0,
                unity_LightColor[0].rgb, 
                unity_LightColor[1].rgb, 
                unity_LightColor[2].rgb, 
                unity_LightColor[3].rgb,
                unity_4LightAtten0, 
                worldPos, 
                worldNormal);
        #endif
        o.sh = ShadeSHPerVertex(worldNormal, o.sh);
    #endif
#endif

    UNITY_TRANSFER_SHADOW(o,v.texcoord1.xy);
    UNITY_TRANSFER_FOG(o,o.pos);
    return o;
}

fixed4 frag_surf(v2f_surf IN) : SV_Target
{
    UNITY_SETUP_INSTANCE_ID(IN);
    float3 worldPos = IN.worldPos;
    float3 worldViewDir = normalize(UnityWorldSpaceViewDir(worldPos));

#ifndef USING_DIRECTIONAL_LIGHT
    fixed3 lightDir = normalize(UnityWorldSpaceLightDir(worldPos));
#else
    fixed3 lightDir = _WorldSpaceLightPos0.xyz;
#endif

    Input surfIN;
    UNITY_INITIALIZE_OUTPUT(Input, surfIN);
    surfIN.uv_MainTex.x = 1.0;
    surfIN.uv_MainTex = IN.pack0.xy;

    SurfaceOutputStandard o;
    UNITY_INITIALIZE_OUTPUT(SurfaceOutputStandard, o);
    o.Albedo = 0.0;
    o.Emission = 0.0;
    o.Alpha = 0.0;
    o.Occlusion = 1.0;
    o.Normal = IN.worldNormal;

    surf(surfIN, o);

    UNITY_LIGHT_ATTENUATION(atten, IN, worldPos)

    fixed4 c = 0;

    UnityGI gi;
    UNITY_INITIALIZE_OUTPUT(UnityGI, gi);
    gi.indirect.diffuse = 0;
    gi.indirect.specular = 0;
    gi.light.color = _LightColor0.rgb;
    gi.light.dir = lightDir;

    UnityGIInput giInput;
    UNITY_INITIALIZE_OUTPUT(UnityGIInput, giInput);
    giInput.light = gi.light;
    giInput.worldPos = worldPos;
    giInput.worldViewDir = worldViewDir;
    giInput.atten = atten;

#if defined(LIGHTMAP_ON) || defined(DYNAMICLIGHTMAP_ON)
    giInput.lightmapUV = IN.lmap;
#else
    giInput.lightmapUV = 0.0;
#endif

#if UNITY_SHOULD_SAMPLE_SH && !UNITY_SAMPLE_FULL_SH_PER_PIXEL
    giInput.ambient = IN.sh;
#else
    giInput.ambient.rgb = 0.0;
#endif

    giInput.probeHDR[0] = unity_SpecCube0_HDR;
    giInput.probeHDR[1] = unity_SpecCube1_HDR;

#if defined(UNITY_SPECCUBE_BLENDING) || defined(UNITY_SPECCUBE_BOX_PROJECTION)
    giInput.boxMin[0] = unity_SpecCube0_BoxMin;
#endif

#ifdef UNITY_SPECCUBE_BOX_PROJECTION
    giInput.boxMax[0] = unity_SpecCube0_BoxMax;
    giInput.probePosition[0] = unity_SpecCube0_ProbePosition;
    giInput.boxMax[1] = unity_SpecCube1_BoxMax;
    giInput.boxMin[1] = unity_SpecCube1_BoxMin;
    giInput.probePosition[1] = unity_SpecCube1_ProbePosition;
#endif

    LightingStandard_GI(o, giInput, gi);
    c += LightingStandard(o, worldViewDir, gi);

    UNITY_APPLY_FOG(IN.fogCoord, c);
    UNITY_OPAQUE_ALPHA(c.a);

    return c;
}

ENDCG

}

構造体(頂点シェーダ → フラグメントシェーダ)

まず、頂点シェーダからフラグメントシェーダへの情報を受け渡す構造体は(少し改変しましたが)以下のようになっています。

struct v2f_surf
{
    UNITY_POSITION(pos);
    float2 pack0 : TEXCOORD0;
    float3 worldNormal : TEXCOORD1;
    float3 worldPos : TEXCOORD2;
    float4 lmap : TEXCOORD3;
    UNITY_SHADOW_COORDS(4)
    UNITY_FOG_COORDS(5)
#ifndef LIGHTMAP_ON
    #if UNITY_SHOULD_SAMPLE_SH
    half3 sh : TEXCOORD6;
    #endif
#endif
    UNITY_VERTEX_INPUT_INSTANCE_ID
    UNITY_VERTEX_OUTPUT_STEREO
};

位置およびUV座標(pack0)に加えて、ワールド空間での法線・位置がピクセル単位でのライティング計算用に渡されています。また特定の条件下のみ球面調和ライティングに使用する sh が追加されています。この条件である LIGHTMAP_ON はライトマップをベイクした時に static なオブジェクトで ON になるフラグで、UNITY_SHOULD_SAMPLE_SH は球面調和ライティングを行うか否かのフラグです。後者のフラグに関しては、UnityCG.cginc で次のように定義されています。

// Should SH (light probe / ambient) calculations be performed?
// - When both static and dynamic lightmaps are available, no SH evaluation is performed
// - When static and dynamic lightmaps are not available, SH evaluation is always performed
// - For low level LODs, static lightmap and real-time GI from light probes can be combined together
// - Passes that don't do ambient (additive, shadowcaster etc.) should not do SH either.
#define UNITY_SHOULD_SAMPLE_SH (defined(LIGHTPROBE_SH) && !defined(UNITY_PASS_FORWARDADD) && !defined(UNITY_PASS_PREPASSBASE) && !defined(UNITY_PASS_SHADOWCASTER) && !defined(UNITY_PASS_META))

いくつか条件が書かれていますが、静的/動的ライトマップが使える時は評価されず、そうでない場合は評価されるという形です。定義を見てみると Add、PrepassBase、ShadowCaster のパス以外(Forward や Deferred)で且つ、LIGHTPROBE_SH が ON のときに TRUE になります。...が、LIGHTPROBE_SH が果たしてどういうときに ON になるか情報がないため、正確には分かりませんでした(少なくとも Static なオブジェクトでは OFF、動的なオブジェクトでは ON になるようです)。

あとは、影、フォグ、インスタンシング、VR 向けシングルパスステレオレンダリングの情報をマクロ経由で追加しています(UNITY_VERTEX_INPUT_INSTANCE_ID および UNITY_VERTEX_OUTPUT_STEREO は特別なセマンティクスが割り振られるので、TEXCOORD の数字は必要ありません)。

頂点シェーダ概要

次に頂点シェーダを見ていきます。

v2f_surf vert_surf(appdata_full v)
{
    // 出力
    v2f_surf o;
    UNITY_INITIALIZE_OUTPUT(v2f_surf, o);

    // インスタンシング関連
    UNITY_SETUP_INSTANCE_ID(v);
    UNITY_TRANSFER_INSTANCE_ID(v, o);
    UNITY_INITIALIZE_VERTEX_OUTPUT_STEREO(o);

    // 位置や法線の計算
    o.pos = UnityObjectToClipPos(v.vertex);
    o.pack0.xy = TRANSFORM_TEX(v.texcoord, _MainTex);
    float3 worldPos = mul(unity_ObjectToWorld, v.vertex).xyz;
    float3 worldNormal = UnityObjectToWorldNormal(v.normal);
    o.worldPos = worldPos;
    o.worldNormal = worldNormal;

    // ライトマップの計算
    ...

    // 球面調和ライティングの計算
    ...

    // 影やフォグの計算
    UNITY_TRANSFER_SHADOW(o,v.texcoord1.xy);
    UNITY_TRANSFER_FOG(o,o.pos);

    return o;
}

最初はインスタンシングやステレオレンダリング向けのおまじないや、構造体の初期化が書かれています。その後はクリップ空間の座標、UV 座標、ワールド座標および法線を求めています。各マクロは中身を追うと大変なので、また別の機会に見ることにしましょう。... 部は次に説明しますが、ライトマップの処理と球面調和ライティングの処理を行います。最後に、フォグとシャドウの処理を行って出力しています。

ライトマップ処理(頂点)

ではまず ... 部前半のライトマップの処理を見ていきます。

#ifdef DYNAMICLIGHTMAP_ON
    o.lmap.zw = v.texcoord2.xy * unity_DynamicLightmapST.xy + unity_DynamicLightmapST.zw;
#endif

#ifdef LIGHTMAP_ON
    o.lmap.xy = v.texcoord1.xy * unity_LightmapST.xy + unity_LightmapST.zw;
#endif

lmap の XY 成分にはベイクされたライトマップの UV 座標を、ZW 成分にはリアルタイム GI の UV を入れます。texcoord1 にライトマップ、texcoord2 にリアルタイム GI の情報が渡ってきているのが分かります。余談ですが、この UV の計算時のオフセットとスケールの適用の式は通常は先ほども pack0 に詰めたように TRANSFORM_TEX() マクロを利用するのですが、このマクロ内では _ST サフィックスを前提にしているので、ST サフィックスなライトマップの変数に対しては使えないので直書きしているようです。

球面調和ライティング(頂点)

では球面調和ライティングの場所を見ていきましょう。ここからが、先の章で見た内容に関係しています。

#ifndef LIGHTMAP_ON
    #if UNITY_SHOULD_SAMPLE_SH && !UNITY_SAMPLE_FULL_SH_PER_PIXEL
        o.sh = 0;
        #ifdef VERTEXLIGHT_ON
            o.sh += Shade4PointLights(
                unity_4LightPosX0, 
                unity_4LightPosY0, 
                unity_4LightPosZ0,
                unity_LightColor[0].rgb, 
                unity_LightColor[1].rgb, 
                unity_LightColor[2].rgb, 
                unity_LightColor[3].rgb,
                unity_4LightAtten0, 
                worldPos, 
                worldNormal);
        #endif
        o.sh = ShadeSHPerVertex(worldNormal, o.sh);
    #endif
#endif

LIGHTMAP_ON が定義されていない(ベイクされたライトマップを使わない動的な物体の)とき且つ、UNITY_SHOULD_SAMPLE_SH(球面調和ライティングを行う)フラグが立っていて更に、 UNITY_SAMPLE_FULL_SH_PER_PIXELピクセル単位で球面調和ライティングを行う)フラグが立っていないときに処理が行われます。まず VERTEXLIGHT_ON が ON のとき、Shade4PointLights() で Pixel Light Count を超える 4 つまでのポイントライトの影響が計算されます。このフラグは、オブジェクトに影響を及ぼすポイントライトが Pixel Light Count よりも存在する場合にのみ ON になります。引数としては、重要でない 4 つのポイントライトの位置を格納した unity_4LightPosX0(他にも Y と Z)や、ポイントライトの色を格納した unity_LightColor といったポイントライトに関係したビルトイン変数、ワールド座標・法線を渡しています。この関数は UnityCG.cginc で次のように定義されています。

// Used in ForwardBase pass: Calculates diffuse lighting from 4 point lights, with data packed in a special way.
float3 Shade4PointLights(
    float4 lightPosX, 
    float4 lightPosY, 
    float4 lightPosZ,
    float3 lightColor0, 
    float3 lightColor1, 
    float3 lightColor2, 
    float3 lightColor3,
    float4 lightAttenSq,
    float3 pos, 
    float3 normal)
{
    // to light vectors
    float4 toLightX = lightPosX - pos.x;
    float4 toLightY = lightPosY - pos.y;
    float4 toLightZ = lightPosZ - pos.z;

    // squared lengths
    float4 lengthSq = 0;
    lengthSq += toLightX * toLightX;
    lengthSq += toLightY * toLightY;
    lengthSq += toLightZ * toLightZ;

    // don't produce NaNs if some vertex position overlaps with the light
    lengthSq = max(lengthSq, 0.000001);

    // NdotL
    float4 ndotl = 0;
    ndotl += toLightX * normal.x;
    ndotl += toLightY * normal.y;
    ndotl += toLightZ * normal.z;

    // correct NdotL
    float4 corr = rsqrt(lengthSq);
    ndotl = max(float4(0,0,0,0), ndotl * corr);

    // attenuation
    float4 atten = 1.0 / (1.0 + lengthSq * lightAttenSq);
    float4 diff = ndotl * atten;

    // final color
    float3 col = 0;
    col += lightColor0 * diff.x;
    col += lightColor1 * diff.y;
    col += lightColor2 * diff.z;
    col += lightColor3 * diff.w;

    return col;
}

内部でやっていることはシンプルで、なるべく効率よくなるよう 4 つのライトの ndotl(法線とライト方向の内積)を求めて、光の減衰を考慮しながら色を足し合わせる、という処理を行っています。つまり、これで最初の 4 つの重要ではないポイントライトの影響が頂点シェーダ内で計算されている、というわけです。

次に、ShadeSHPerVertex() によって頂点シェーダ内での球面調和ライティングの計算がなされます。これは UnityStandardUtils.cginc 内で次のように定義されています。

half3 ShadeSHPerVertex(half3 normal, half3 ambient)
{
#if UNITY_SAMPLE_FULL_SH_PER_PIXEL
    // Completely per-pixel
    // nothing to do here
#elif (SHADER_TARGET < 30) || UNITY_STANDARD_SIMPLE
    // Completely per-vertex
    ambient += max(half3(0, 0, 0), ShadeSH9(half4(normal, 1.0)));
#else
    // L2 per-vertex, L0..L1 & gamma-correction per-pixel

    // NOTE: SH data is always in Linear AND calculation is split between vertex & pixel
    // Convert ambient to Linear and do final gamma-correction at the end (per-pixel)
    #ifdef UNITY_COLORSPACE_GAMMA
        ambient = GammaToLinearSpace(ambient);
    #endif
    ambient += SHEvalLinearL2(half4(normal, 1.0));     // no max since this is only L2 contribution
#endif

    return ambient;
}

まず、UNITY_SAMPLE_FULL_SH_PER_PIXEL が ON の時、つまり球面調和ライティングを完全にピクセル単位(フラグメントシェーダ内)で行う時は処理はスキップされます *2

次に Shader Model 3.0 よりも古いバージョンまたは UNITY_STANDARD_SIMPLE のフラグが ON のときには、ShaderSH9() という関数が実行されます。このフラグは UnityStandardCoreForward.cgincUNITY_NO_FULL_STANDARD_SHADER が定義されているときに ON になります(マニュアル)。定義した場合にはいくつか簡略化したスタンダードシェーダの処理が走るのですが、この ShaderSH9() もその1つです。

この解説に進む前に少し #else 節の話もしておきます。これまで見たように、#if 節と #elif 節は特定の条件下に通るもので、通常は #else 節が走ります。で、#else 節では球面調和の中でも周波数の高い L2 だけを計算し、L0 と L1 はフラグメントシェーダ側で行います。理由は記述がなかったので推測になるのですが(どなたか詳しい方教えてください)、次数が上がるにつれ考慮しないとならないパターンも増え計算量も増えます(参考: 球面調和関数表 - Wikipedia)。なので最も計算量の多い L2 だけはグラデーションの綺麗さを犠牲にしてパフォーマンスを稼ぐため頂点シェーダで行い、L0 と L1 は計算もさほど重くないため、綺麗さを重視してフラグメントシェーダで行っているのかな、と考えています。

で、話を戻すと UNITY_STANDARD_SIMPLE フラグが立っている時は、強制的にこのフラグメントシェーダ側で走る L0 および L1 計算を切って頂点シェーダで行うようになります。つまり頂点が荒い場合は汚くなるものの、パフォーマンスは稼げるというわけです。次に定義を示します。

half3 ShadeSH9(half4 normal)
{
    // Linear + constant polynomial terms
    half3 res = SHEvalLinearL0L1(normal);

    // Quadratic polynomials
    res += SHEvalLinearL2(normal);

#ifdef UNITY_COLORSPACE_GAMMA
        res = LinearToGammaSpace(res);
#endif

    return res;
}

L0 および L1 を計算する SHEvalLinearL0L1() と L2 を計算する SHEvalLinearL2() が両方共行われています。どうせなので、ここも掘り下げてみてみましょう。

half3 SHEvalLinearL0L1(half4 normal)
{
    half3 x;

    // Linear (L1) + constant (L0) polynomial terms
    x.r = dot(unity_SHAr,normal);
    x.g = dot(unity_SHAg,normal);
    x.b = dot(unity_SHAb,normal);

    return x;
}

まず L0 と L1 についてです。unity_SHAr など、RGB 別にそれぞれ強度がベクトルで格納されています。これと法線の内積を取って最終的な強度を算出しています。

// normal should be normalized, w=1.0
half3 SHEvalLinearL2(half4 normal)
{
    half3 x1, x2;

    // 4 of the quadratic (L2) polynomials
    half4 vB = normal.xyzz * normal.yzzx;
    x1.r = dot(unity_SHBr,vB);
    x1.g = dot(unity_SHBg,vB);
    x1.b = dot(unity_SHBb,vB);

    // Final (5th) quadratic (L2) polynomial
    half vC = normal.x*normal.x - normal.y*normal.y;
    x2 = unity_SHC.rgb * vC;

    return x1 + x2;
}

L2 については、また別の unity_SHBrunity_SHC といったベクトルが格納されていて、計算しています。正直ここで具体的に何を計算しているのかわからなかったのでギブアップです...。#else 節では、この L2 のみが行われるようになっています。

ちょっと疲れたかもしれませんが、まだ折り返し地点。。これでようやく頂点シェーダが終わりました。

フラグメントシェーダ概要

次にフラグメントシェーダの流れを見ていきます。まずはおおまかに処理の流れを以下に示します。

fixed4 frag_surf(v2f_surf IN) : SV_Target
{
    // 変数の初期化など
    ...

    // 構造体に情報を詰めてサーフェスシェーダの実行
    Input surfIN;
    ...
    SurfaceOutputStandard o;
    ...
    surf(surfIN, o);

    ...

    // 出力
    fixed4 c = 0;

    // ライティング用の情報を詰める
    UnityGI gi;
    ...
    UnityGIInput giInput;
    ...

    // ライティングの実行
    LightingStandard_GI(o, giInput, gi);
    c += LightingStandard(o, worldViewDir, gi);

    ...

    return c;
}

全体としては UnityGIUnityGIInput にライトや座標などの情報を詰めて関数に渡して BRDF を計算する流れです。

初期化部(フラグメント)

細かく見ていきましょう。まずは初期化部です。

UNITY_SETUP_INSTANCE_ID(IN);
float3 worldPos = IN.worldPos;
float3 worldViewDir = normalize(UnityWorldSpaceViewDir(worldPos));

#ifndef USING_DIRECTIONAL_LIGHT
fixed3 lightDir = normalize(UnityWorldSpaceLightDir(worldPos));
#else
fixed3 lightDir = _WorldSpaceLightPos0.xyz;
#endif

頂点シェーダから渡された座標を使ってワールド空間での現在のピクセルの位置およびカメラとライトの方向を求めています。UnityShaderVariables.cgincDIRECTIONAL キーワードが ON になっているときに USING_DIRECTIONAL_LIGHT が定義されるので、それを見てディレクショナルライトなのかポイントライトなのか判断しています(DIRECTIONAL はベースパス時であれば常に ON のようです)。

サーフェスシェーダの展開

次にサーフェスシェーダ部を見てみます。

Input surfIN;
UNITY_INITIALIZE_OUTPUT(Input, surfIN);
surfIN.uv_MainTex.x = 1.0;
surfIN.uv_MainTex = IN.pack0.xy;

SurfaceOutputStandard o;
UNITY_INITIALIZE_OUTPUT(SurfaceOutputStandard, o);
o.Albedo = 0.0;
o.Emission = 0.0;
o.Alpha = 0.0;
o.Occlusion = 1.0;
o.Normal = IN.worldNormal;

surf(surfIN, o);

サーフェスシェーダの入力である surfIN に頂点シェーダから送られてきた情報を詰め、サーフェスシェーダを実行しています。ここではサーフェスシェーダとして書いたコードがそのまま実行されます。

void surf(Input IN, inout SurfaceOutputStandard o)
{
    fixed4 c = tex2D(_MainTex, IN.uv_MainTex) * _Color;
    o.Albedo = c.rgb;
    o.Metallic = _Metallic;
    o.Smoothness = _Glossiness;
    o.Alpha = c.a;
}

ここでは何もしてないデフォルトのサーフェスシェーダを見ているので、パラメタを代入しているだけの形です。自身でサーフェスシェーダをいろいろ編集した場合はここでその処理がフックされるわけですね。

ライト減衰

次はライトによる影と陰の計算です。

UNITY_LIGHT_ATTENUATION(atten, IN, worldPos)

ここでは、ライトの減衰および他のオブジェクトでできた影を計算しています。第 1 引数で渡した atten という名前の変数が INworldPos を使って計算されます。ちょっと潜るとだいぶガッツのある場合分け(ライトの種類やプラットフォーム)があるので、ここではスキップしますが、例えばディレクショナルライトに関しては、影が 0 / 1 で計算されるので、この atten を 0 にすると、全部が影になり 1 にすると常に明るくなります。

ライティングのための準備

では次に本丸のライティング部分を見ていきます。ちょっと長いですが全文を貼ります。

fixed4 c = 0;

UnityGI gi;
UNITY_INITIALIZE_OUTPUT(UnityGI, gi);
gi.indirect.diffuse = 0;
gi.indirect.specular = 0;
gi.light.color = _LightColor0.rgb;
gi.light.dir = lightDir;

UnityGIInput giInput;
UNITY_INITIALIZE_OUTPUT(UnityGIInput, giInput);
giInput.light = gi.light;
giInput.worldPos = worldPos;
giInput.worldViewDir = worldViewDir;
giInput.atten = atten;

#if defined(LIGHTMAP_ON) || defined(DYNAMICLIGHTMAP_ON)
giInput.lightmapUV = IN.lmap;
#else
giInput.lightmapUV = 0.0;
#endif

#if UNITY_SHOULD_SAMPLE_SH && !UNITY_SAMPLE_FULL_SH_PER_PIXEL
giInput.ambient = IN.sh;
#else
giInput.ambient.rgb = 0.0;
#endif

giInput.probeHDR[0] = unity_SpecCube0_HDR;
giInput.probeHDR[1] = unity_SpecCube1_HDR;

#if defined(UNITY_SPECCUBE_BLENDING) || defined(UNITY_SPECCUBE_BOX_PROJECTION)
giInput.boxMin[0] = unity_SpecCube0_BoxMin;
#endif

#ifdef UNITY_SPECCUBE_BOX_PROJECTION
giInput.boxMax[0] = unity_SpecCube0_BoxMax;
giInput.probePosition[0] = unity_SpecCube0_ProbePosition;
giInput.boxMax[1] = unity_SpecCube1_BoxMax;
giInput.boxMin[1] = unity_SpecCube1_BoxMin;
giInput.probePosition[1] = unity_SpecCube1_ProbePosition;
#endif

LightingStandard_GI(o, giInput, gi);
c += LightingStandard(o, worldViewDir, gi);

はじめから見ていってもいいのですが、ここでの目的は最終的には BRDF による物理ベースシェーディングを行う UNITY_BRDF_PBS() 関数を計算して該当ピクセルの色を決定することです。なので最後から見ていってどういう情報が必要なのか把握しておきましょう。LightingStandard()UnityPBSLighting.cginc に書いてあります。

inline half4 LightingStandard(SurfaceOutputStandard s, float3 viewDir, UnityGI gi)
{
    s.Normal = normalize(s.Normal);

    half oneMinusReflectivity;
    half3 specColor;
    s.Albedo = DiffuseAndSpecularFromMetallic(s.Albedo, s.Metallic, /*out*/ specColor, /*out*/ oneMinusReflectivity);

    half outputAlpha;
    s.Albedo = PreMultiplyAlpha(s.Albedo, s.Alpha, oneMinusReflectivity, /*out*/ outputAlpha);

    half4 c = UNITY_BRDF_PBS(s.Albedo, specColor, oneMinusReflectivity, s.Smoothness, s.Normal, viewDir, gi.light, gi.indirect);
    c.a = outputAlpha;
    return c;
}

LightingStandard() の中で UNITY_BRDF_PBS() を実行しています。入力としては、アルベド色、スペキュラ色、1 - 反射率、スムースネス、法線、ビュー方向、そして UnityGI に格納されているライト情報と間接光情報です。DiffuseAndSpecularFromMetallic() でメタリックパラメタからアルベド、スペキュラ、反射率を計算し、PreMultiplyAlpha() では _ALPHAPREMULTIPLY_ON の時に乗算済みアルファのための処理を行うようです(アルベドにアルファをかけて oneMinusReflectivity に従ってアルファを修正)。ここで入力として与えている SurfaceOutputStandardサーフェスシェーダの出力で、UnityGI は次のような構造体です。

struct UnityLight
{
    half3 color;
    half3 dir;
    half  ndotl; // Deprecated: Ndotl is now calculated on the fly and is no longer stored. Do not used it.
};

struct UnityIndirect
{
    half3 diffuse;
    half3 specular;
};

struct UnityGI
{
    UnityLight light;
    UnityIndirect indirect;
};

メインのライトの光の情報と、間接光の影響を格納する構造体になってます。この内、間接光成分については UnityGIInput 構造体を LightingStandard_GI() の入力として与えることで計算します(ライトも atten による補正が入ります)。UnityGIInput は以下のようになっています。

struct UnityGIInput
{
    UnityLight light;
    float3 worldPos;
    half3 worldViewDir;
    half atten;
    half3 ambient;
    float4 lightmapUV; // .xy は静的なライトマップ UV、.zw は動的なライトマップの UV
#if defined(UNITY_SPECCUBE_BLENDING) || defined(UNITY_SPECCUBE_BOX_PROJECTION) || defined(UNITY_ENABLE_REFLECTION_BUFFERS)
    float4 boxMin[2];
#endif
#ifdef UNITY_SPECCUBE_BOX_PROJECTION
    float4 boxMax[2];
    float4 probePosition[2];
#endif
    float4 probeHDR[2];
};

再びライトの情報と、ワールド座標、ビュー方向、光の減衰、アンビエント成分、更にライトマップの UV、これに加えてリフレクションプローブ関連のパラメタが条件付きでいくつか続きます。リフレクションプローブ関連の変数はブレンド用に 2 つ用意されていたり、Box Projection を ON にしたとき用にいくつかの変数が追加されています(Graphics Settings の Tier や Re。ブレンドなどについてはマニュアルをご参照ください。

docs.unity3d.com

これらの変数を埋められるだけ埋めて LightingStandard_GI()UnityIndirect を求め、それを使って LightingStandard()BRDF を計算する、という流れになります。

それでは変数を埋める場所を見ていきましょう。

UnityGI gi;
UNITY_INITIALIZE_OUTPUT(UnityGI, gi);
gi.indirect.diffuse = 0;
gi.indirect.specular = 0;
gi.light.color = _LightColor0.rgb;
gi.light.dir = lightDir;

UnityGIInput giInput;
UNITY_INITIALIZE_OUTPUT(UnityGIInput, giInput);
giInput.light = gi.light;
giInput.worldPos = worldPos;
giInput.worldViewDir = worldViewDir;
giInput.atten = atten;

まずはこれまで初期化部で得た変数などを代入します。次にライトマップの UV の格納です。

#if defined(LIGHTMAP_ON) || defined(DYNAMICLIGHTMAP_ON)
    giInput.lightmapUV = IN.lmap;
#else
    giInput.lightmapUV = 0.0;
#endif

静的または動的なライトマップどちらかが ON になっていたら代入します。XY 成分にベイクされたライトマップの UV が、ZW に GI 用の UV が入っています。次は頂点シェーダで計算した SH の受け渡しです。

#if UNITY_SHOULD_SAMPLE_SH && !UNITY_SAMPLE_FULL_SH_PER_PIXEL
    giInput.ambient = IN.sh;
#else
    giInput.ambient.rgb = 0.0;
#endif

頂点シェーダで計算が走っている条件のときのみ値を代入しています。最後はリフレクションプローブの変数です。

    giInput.probeHDR[0] = unity_SpecCube0_HDR;
    giInput.probeHDR[1] = unity_SpecCube1_HDR;

#if defined(UNITY_SPECCUBE_BLENDING) || defined(UNITY_SPECCUBE_BOX_PROJECTION)
    giInput.boxMin[0] = unity_SpecCube0_BoxMin;
#endif

#ifdef UNITY_SPECCUBE_BOX_PROJECTION
    giInput.boxMax[0] = unity_SpecCube0_BoxMax;
    giInput.probePosition[0] = unity_SpecCube0_ProbePosition;
    giInput.boxMax[1] = unity_SpecCube1_BoxMax;
    giInput.boxMin[1] = unity_SpecCube1_BoxMin;
    giInput.probePosition[1] = unity_SpecCube1_ProbePosition;
#endif

Box Projection かどうかなどを見ながら組み込み変数を代入しています。これで準備が整いました。

LightingStandard_GI の計算

まずは LightingStandard_GI() を処理します。

LightingStandard_GI(o, giInput, gi);

UnityPBSLighting.cginc を見てみると次のように定義されています。

inline void LightingStandard_GI(
    SurfaceOutputStandard s,
    UnityGIInput data,
    inout UnityGI gi)
{
#if defined(UNITY_PASS_DEFERRED) && UNITY_ENABLE_REFLECTION_BUFFERS
    gi = UnityGlobalIllumination(data, s.Occlusion, s.Normal);
#else
    Unity_GlossyEnvironmentData g = UnityGlossyEnvironmentSetup(
        s.Smoothness, 
        data.worldViewDir, 
        s.Normal, 
        lerp(unity_ColorSpaceDielectricSpec.rgb, s.Albedo, s.Metallic));
    gi = UnityGlobalIllumination(data, s.Occlusion, s.Normal, g);
#endif
}

フォワードでは #else 側が走ります。UnityGlossyEnvironmentSetup() では次のようにスムースネスからラフネスへの変換と反射光を求める計算を行っています。

// UnityImageBasedLighting.cginc
struct Unity_GlossyEnvironmentData
{
    half roughness; // perceptualRoughness
    half3 reflUVW;
};

Unity_GlossyEnvironmentData UnityGlossyEnvironmentSetup(
    half Smoothness, 
    half3 worldViewDir, 
    half3 Normal, 
    half3 fresnel0)
{
    Unity_GlossyEnvironmentData g;

    g.roughness = SmoothnessToPerceptualRoughness(Smoothness);
    g.reflUVW = reflect(-worldViewDir, Normal);

    return g;
}

// UnityStandardBRDF.cginc
float SmoothnessToPerceptualRoughness(float smoothness)
{
    return (1 - smoothness);
}

UnityGlobalIllumination()UnityGlobalIllumination.cginc で次のような処理を行っています。

inline UnityGI UnityGlobalIllumination(
    UnityGIInput data, 
    half occlusion, 
    half3 normalWorld, 
    Unity_GlossyEnvironmentData glossIn)
{
    UnityGI o_gi = UnityGI_Base(data, occlusion, normalWorld);
    o_gi.indirect.specular = UnityGI_IndirectSpecular(data, occlusion, glossIn);
    return o_gi;
}

拡散成分を UnityGI_Base() で求め、スペキュラ成分を UnityGI_IndirectSpecular() で求めています。まずは前者から見てみましょう。

inline UnityGI UnityGI_Base(
    UnityGIInput data, 
    half occlusion, 
    half3 normalWorld)
{
    UnityGI o_gi;
    ResetUnityGI(o_gi);

    // 影の処理、atten を修正してベイク影とリアルタイム影のブレンディングなど
    #if defined(HANDLE_SHADOWS_BLENDING_IN_GI)
        half bakedAtten = UnitySampleBakedOcclusion(data.lightmapUV.xy, data.worldPos);
        float zDist = dot(_WorldSpaceCameraPos - data.worldPos, UNITY_MATRIX_V[2].xyz);
        float fadeDist = UnityComputeShadowFadeDistance(data.worldPos, zDist);
        data.atten = UnityMixRealtimeAndBakedShadows(data.atten, bakedAtten, UnityComputeShadowFade(fadeDist));
    #endif

    // ライトの減衰
    o_gi.light = data.light;
    o_gi.light.color *= data.atten;

    // SH の計算、ここでは L0 と L1 の計算を行う
    #if UNITY_SHOULD_SAMPLE_SH
        o_gi.indirect.diffuse = ShadeSHPerPixel(normalWorld, data.ambient, data.worldPos);
    #endif

    // ベイクされたライトマップの処理、lightmapUV.xy を使って gi.indirect.diffuse を更新
    #if defined(LIGHTMAP_ON)
        half4 bakedColorTex = UNITY_SAMPLE_TEX2D(unity_Lightmap, data.lightmapUV.xy);
        half3 bakedColor = DecodeLightmap(bakedColorTex);

        #ifdef DIRLIGHTMAP_COMBINED
            fixed4 bakedDirTex = UNITY_SAMPLE_TEX2D_SAMPLER (unity_LightmapInd, unity_Lightmap, data.lightmapUV.xy);
            o_gi.indirect.diffuse += DecodeDirectionalLightmap (bakedColor, bakedDirTex, normalWorld);
            #if defined(LIGHTMAP_SHADOW_MIXING) && !defined(SHADOWS_SHADOWMASK) && defined(SHADOWS_SCREEN)
                ResetUnityLight(o_gi.light);
                o_gi.indirect.diffuse = SubtractMainLightWithRealtimeAttenuationFromLightmap (o_gi.indirect.diffuse, data.atten, bakedColorTex, normalWorld);
            #endif
        #else
            o_gi.indirect.diffuse += bakedColor;
            #if defined(LIGHTMAP_SHADOW_MIXING) && !defined(SHADOWS_SHADOWMASK) && defined(SHADOWS_SCREEN)
                ResetUnityLight(o_gi.light);
                o_gi.indirect.diffuse = SubtractMainLightWithRealtimeAttenuationFromLightmap(o_gi.indirect.diffuse, data.atten, bakedColorTex, normalWorld);
            #endif
        #endif
    #endif

    // リアルタイム GI の処理、lightmapUV.zw を使って gi.indirect.diffuse を更新
    #ifdef DYNAMICLIGHTMAP_ON
        fixed4 realtimeColorTex = UNITY_SAMPLE_TEX2D(unity_DynamicLightmap, data.lightmapUV.zw);
        half3 realtimeColor = DecodeRealtimeLightmap (realtimeColorTex);

        #ifdef DIRLIGHTMAP_COMBINED
            half4 realtimeDirTex = UNITY_SAMPLE_TEX2D_SAMPLER(unity_DynamicDirectionality, unity_DynamicLightmap, data.lightmapUV.zw);
            o_gi.indirect.diffuse += DecodeDirectionalLightmap(realtimeColor, realtimeDirTex, normalWorld);
        #else
            o_gi.indirect.diffuse += realtimeColor;
        #endif
    #endif

    // オクルージョンの反映は最後に
    o_gi.indirect.diffuse *= occlusion;

    return o_gi;
}

頂点シェーダの方で通常では SH の L2 だけを計算していました。その場合、ShadeSHPerPixel() で L0 と L1 を計算することになります。中の実装をちら見してみましょう。

half3 ShadeSHPerPixel(half3 normal, half3 ambient, float3 worldPos)
{
    half3 ambient_contrib = 0.0;

    #if UNITY_SAMPLE_FULL_SH_PER_PIXEL
        // L2 もフラグメントシェーダでおこない場合(ここでは省略)
        ...
    #elif (SHADER_TARGET < 30) || UNITY_STANDARD_SIMPLE
        // 頂点シェーダで行っている
    #else
        #if UNITY_LIGHT_PROBE_PROXY_VOLUME
            if (unity_ProbeVolumeParams.x == 1.0)
                ambient_contrib = SHEvalLinearL0L1_SampleProbeVolume (half4(normal, 1.0), worldPos);
            else
                ambient_contrib = SHEvalLinearL0L1 (half4(normal, 1.0));
        #else
            ambient_contrib = SHEvalLinearL0L1 (half4(normal, 1.0));
        #endif

        ambient = max(half3(0, 0, 0), ambient + ambient_contrib);
        #ifdef UNITY_COLORSPACE_GAMMA
            ambient = LinearToGammaSpace(ambient);
        #endif
    #endif

    return ambient;
}

他にもいろいろと処理がありますが、これらについても更に中に潜るとめちゃめちゃしんどそうなのでスキップします。とりあえずブロック単位でどういった処理が行われているかは理解できるのではないでしょうか。

次にスペキュラ成分です。

inline half3 UnityGI_IndirectSpecular(
    UnityGIInput data, 
    half occlusion, 
    Unity_GlossyEnvironmentData glossIn)
{
    half3 specular;

    // Box Projection 時は reflUVW を修正
    #ifdef UNITY_SPECCUBE_BOX_PROJECTION
        half3 originalReflUVW = glossIn.reflUVW;
        glossIn.reflUVW = BoxProjectedCubemapDirection(originalReflUVW, data.worldPos, data.probePosition[0], data.boxMin[0], data.boxMax[0]);
    #endif

    // リフレクションプローブのサンプリング
    #ifdef _GLOSSYREFLECTIONS_OFF
        specular = unity_IndirectSpecColor.rgb;
    #else
        half3 env0 = Unity_GlossyEnvironment(UNITY_PASS_TEXCUBE(unity_SpecCube0), data.probeHDR[0], glossIn);

        // 2 つある場合は同様に処理して lerp
        #ifdef UNITY_SPECCUBE_BLENDING
            const float kBlendFactor = 0.99999;
            float blendLerp = data.boxMin[0].w;
            UNITY_BRANCH
            if (blendLerp < kBlendFactor)
            {
                #ifdef UNITY_SPECCUBE_BOX_PROJECTION
                    glossIn.reflUVW = BoxProjectedCubemapDirection (originalReflUVW, data.worldPos, data.probePosition[1], data.boxMin[1], data.boxMax[1]);
                #endif

                half3 env1 = Unity_GlossyEnvironment (UNITY_PASS_TEXCUBE_SAMPLER(unity_SpecCube1,unity_SpecCube0), data.probeHDR[1], glossIn);
                specular = lerp(env1, env0, blendLerp);
            }
            else
            {
                specular = env0;
            }
        #else
            specular = env0;
        #endif
    #endif

    // こちらもオクルージョンを反映して返す
    return specular * occlusion;
}

リフレクションプローブのサンプリングを行っています。Box Projection 時だけ UVW を調整しているのが分かります。こうして得られた UVW を使ってキューブマップのサンプリングを Unity_GlossyEnvironment() で行います。

half3 Unity_GlossyEnvironment(
    UNITY_ARGS_TEXCUBE(tex), 
    half4 hdr, 
    Unity_GlossyEnvironmentData glossIn)
{
    half perceptualRoughness = glossIn.roughness /* perceptualRoughness */ ;
    perceptualRoughness = perceptualRoughness * (1.7 - 0.7 * perceptualRoughness);

    half mip = perceptualRoughnessToMipmapLevel(perceptualRoughness);
    half3 R = glossIn.reflUVW;
    half4 rgbm = UNITY_SAMPLE_TEXCUBE_LOD(tex, R, mip);

    return DecodeHDR(rgbm, hdr);
}

half perceptualRoughnessToMipmapLevel(half perceptualRoughness)
{
    return perceptualRoughness * UNITY_SPECCUBE_LOD_STEPS;
}

最終的に UNITY_SAMPLE_TEXTURE_LOD の中で行う texCUBEbias で UVW は座標、ラフネスはミップレベルとして扱われサンプリングされて値が返ってきます。

こうして UNITY_BRDF_PBS() に必要な情報が集まりました。途中結果を出力してみると以下のようになっています。

最終的な画

f:id:hecomi:20180530092432p:plain

gi.light.color

f:id:hecomi:20180529223245p:plain

gi.indirect.diffuse

f:id:hecomi:20180529223303p:plain

gi.indirect.specular

f:id:hecomi:20180529223317p:plain

LightingStandard の計算

さていよいよ最終段階です。コードを再掲します。

inline half4 LightingStandard(SurfaceOutputStandard s, float3 viewDir, UnityGI gi)
{
    s.Normal = normalize(s.Normal);

    half oneMinusReflectivity;
    half3 specColor;
    s.Albedo = DiffuseAndSpecularFromMetallic(s.Albedo, s.Metallic, /*out*/ specColor, /*out*/ oneMinusReflectivity);

    half outputAlpha;
    s.Albedo = PreMultiplyAlpha(s.Albedo, s.Alpha, oneMinusReflectivity, /*out*/ outputAlpha);

    half4 c = UNITY_BRDF_PBS(s.Albedo, specColor, oneMinusReflectivity, s.Smoothness, s.Normal, viewDir, gi.light, gi.indirect);
    c.a = outputAlpha;
    return c;
}

UNITY_BRDF_PBSUnityPBSLighting.cginc で次のように定義されています。

#if !defined (UNITY_BRDF_PBS)
    #if SHADER_TARGET < 30
        #define UNITY_BRDF_PBS BRDF3_Unity_PBS
    #elif defined(UNITY_PBS_USE_BRDF3)
        #define UNITY_BRDF_PBS BRDF3_Unity_PBS
    #elif defined(UNITY_PBS_USE_BRDF2)
        #define UNITY_BRDF_PBS BRDF2_Unity_PBS
    #elif defined(UNITY_PBS_USE_BRDF1)
        #define UNITY_BRDF_PBS BRDF1_Unity_PBS
    #elif defined(SHADER_TARGET_SURFACE_ANALYSIS)
        #define UNITY_BRDF_PBS BRDF1_Unity_PBS
    #else
        #error something broke in auto-choosing BRDF
    #endif
#endif

ユーザが自分でも BRDF を定義できるようになっているので、まず定義されてないかチェックしたあと、条件分岐でどれかの BRDF を選択します。どの BRDF を使用するかは Graphics SettingsTier SettingsStandard Shader Quality で選択します。

f:id:hecomi:20180530011109p:plain

High、Middle、Low の順に UNITY_PBS_USE_BRDF1UNITY_PBS_USE_BRDF2UNITY_PBS_USE_BRDF3 が定義されます。これらに応じて異なる BRDF の関数が呼ばれます。この定義は UnityStandardBRDF.cginc で行われています。しかしこれらについて書く知識がないのと、書いていたらものすごく長くなってしまいそうなのでここでは省略して概要だけ説明することにします。

  • BRDF1_Unity_PBS
    • High 設定では、Disney の BRDF から派生した Torrance-Sparrow モデルを使用したものとのこと
  • BRDF2_Unity_PBS
  • BRDF3_Unity_PBS
    • Low 設定では、Modified Normalized Blinn-Phong BRDF

f:id:hecomi:20180530014140g:plain

フォグ、アルファの適用

最後です。

UNITY_APPLY_FOG(IN.fogCoord, c);
UNITY_OPAQUE_ALPHA(c.a);

UNITY_APPLY_FOG()UnityCG.cginc で定義されていてフォグの種類に応じて場合分けがなされます。

#ifdef UNITY_PASS_FORWARDADD
    // 加算パスのときは黒とブレンドして光を薄くするだけ
    #define UNITY_APPLY_FOG(coord,col) UNITY_APPLY_FOG_COLOR(coord,col,fixed4(0,0,0,0))
#else
    // ベースパスではこれまでの色の col と unity_FogColor とブレンド
    #define UNITY_APPLY_FOG(coord,col) UNITY_APPLY_FOG_COLOR(coord,col,unity_FogColor)
#endif

// Fog を設定した場合はここが実行される
#if defined(FOG_LINEAR) || defined(FOG_EXP) || defined(FOG_EXP2)
    #if (SHADER_TARGET < 30) || defined(SHADER_API_MOBILE)
        #define UNITY_APPLY_FOG_COLOR(coord,col,fogCol) UNITY_FOG_LERP_COLOR(col,fogCol,(coord).x)
    #else
        #define UNITY_APPLY_FOG_COLOR(coord,col,fogCol) UNITY_CALC_FOG_FACTOR((coord).x); UNITY_FOG_LERP_COLOR(col,fogCol,unityFogFactor)
    #endif
// Fog を設定してない場合はここを通り、何もしないブレンドになる
#else
    #define UNITY_APPLY_FOG_COLOR(coord,col,fogCol)
#endif

#define UNITY_CALC_FOG_FACTOR(coord) UNITY_CALC_FOG_FACTOR_RAW(UNITY_Z_0_FAR_FROM_CLIPSPACE(coord))

// 選択した Fog の Mode に応じてブレンド
#if defined(FOG_LINEAR)
    #define UNITY_CALC_FOG_FACTOR_RAW(coord) float unityFogFactor = (coord) * unity_FogParams.z + unity_FogParams.w
#elif defined(FOG_EXP)
    #define UNITY_CALC_FOG_FACTOR_RAW(coord) float unityFogFactor = unity_FogParams.y * (coord); unityFogFactor = exp2(-unityFogFactor)
#elif defined(FOG_EXP2)
    #define UNITY_CALC_FOG_FACTOR_RAW(coord) float unityFogFactor = unity_FogParams.x * (coord); unityFogFactor = exp2(-unityFogFactor*unityFogFactor)
#else
    #define UNITY_CALC_FOG_FACTOR_RAW(coord) float unityFogFactor = 0.0
#endif

f:id:hecomi:20180530091651p:plain

なお、頂点シェーダの方では説明を省略してしまいましたが、UNITY_TRANSFER_FOG() では距離の計算を行って coord.x にそれを格納しています。つまり、頂点シェーダでは距離の計算を行い、フォグの色の適用はフラグメントシェーダで行う、という形になっています。

UNITY_OPAQUE_ALPHA() はアルファを代入するだけです。

#define UNITY_OPAQUE_ALPHA(outputAlpha) outputAlpha = 1.0

これでベースパスについては一通り終わりました。

加算パス

加算パスはベースパスのコードが分かっていればそれほど大変ではありません。差分だけ見ていけば理解できると思います。

全文

まずは同じく全文から。ベースパスに比べるとかなり場合分けが少ないように見えます(もちろん各関数やマクロの中では分岐がありますが)。これは、加算パスでははじめの章で見たように、追加のライトについて処理を行うため、追加のライトに関係ない情報(ライトマップなど)を含まないためです。

Pass
{

Name "FORWARD"
Tags { "LightMode" = "ForwardAdd" }
ZWrite Off Blend One One

CGPROGRAM
#pragma vertex vert_surf
#pragma fragment frag_surf
#pragma target 3.0
#pragma multi_compile_instancing
#pragma multi_compile_fog
#pragma skip_variants INSTANCING_ON
#pragma multi_compile_fwdadd_fullshadows
#include "HLSLSupport.cginc"
#include "UnityShaderVariables.cginc"
#include "UnityShaderUtilities.cginc"

#define UNITY_PASS_FORWARDADD
#include "UnityCG.cginc"
#include "Lighting.cginc"
#include "UnityPBSLighting.cginc"
#include "AutoLight.cginc"

sampler2D _MainTex;
float4 _MainTex_ST;
half _Glossiness;
half _Metallic;
fixed4 _Color;

struct Input
{
    float2 uv_MainTex;
};

void surf(Input IN, inout SurfaceOutputStandard o)
{
    fixed4 c = tex2D(_MainTex, IN.uv_MainTex) * _Color;
    o.Albedo = c.rgb;
    o.Metallic = _Metallic;
    o.Smoothness = _Glossiness;
    o.Alpha = c.a;
}

struct v2f_surf
{
    UNITY_POSITION(pos);
    float2 pack0 : TEXCOORD0;
    float3 worldNormal : TEXCOORD1;
    float3 worldPos : TEXCOORD2;
    UNITY_SHADOW_COORDS(3)
    UNITY_FOG_COORDS(4)
    UNITY_VERTEX_INPUT_INSTANCE_ID
    UNITY_VERTEX_OUTPUT_STEREO
};

v2f_surf vert_surf(appdata_full v)
{
    UNITY_SETUP_INSTANCE_ID(v);
    v2f_surf o;
    UNITY_INITIALIZE_OUTPUT(v2f_surf,o);
    UNITY_TRANSFER_INSTANCE_ID(v,o);
    UNITY_INITIALIZE_VERTEX_OUTPUT_STEREO(o);
    o.pos = UnityObjectToClipPos(v.vertex);
    o.pack0.xy = TRANSFORM_TEX(v.texcoord, _MainTex);
    o.worldPos = mul(unity_ObjectToWorld, v.vertex).xyz;
    o.worldNormal = UnityObjectToWorldNormal(v.normal);

    UNITY_TRANSFER_SHADOW(o,v.texcoord1.xy);
    UNITY_TRANSFER_FOG(o,o.pos);
    return o;
}

fixed4 frag_surf(v2f_surf IN) : SV_Target
{
    UNITY_SETUP_INSTANCE_ID(IN);

    Input surfIN;
    UNITY_INITIALIZE_OUTPUT(Input,surfIN);
    surfIN.uv_MainTex.x = 1.0;
    surfIN.uv_MainTex = IN.pack0.xy;

    float3 worldPos = IN.worldPos;
    float3 worldViewDir = normalize(UnityWorldSpaceViewDir(worldPos));
#ifndef USING_DIRECTIONAL_LIGHT
    fixed3 lightDir = normalize(UnityWorldSpaceLightDir(worldPos));
#else
    fixed3 lightDir = _WorldSpaceLightPos0.xyz;
#endif

    SurfaceOutputStandard o;
    UNITY_INITIALIZE_OUTPUT(SurfaceOutputStandard, o);
    o.Albedo = 0.0;
    o.Emission = 0.0;
    o.Alpha = 0.0;
    o.Occlusion = 1.0;
    o.Normal = IN.worldNormal;

    surf(surfIN, o);
    UNITY_LIGHT_ATTENUATION(atten, IN, worldPos)
    fixed4 c = 0;

    UnityGI gi;
    UNITY_INITIALIZE_OUTPUT(UnityGI, gi);
    gi.indirect.diffuse = 0;
    gi.indirect.specular = 0;
    gi.light.color = _LightColor0.rgb;
    gi.light.dir = lightDir;
    gi.light.color *= atten;

    c += LightingStandard(o, worldViewDir, gi);
    c.a = 0.0;

    UNITY_APPLY_FOG(IN.fogCoord, c);
    UNITY_OPAQUE_ALPHA(c.a);

    return c;
}

ENDCG

}

構造体

頂点シェーダからフラグメントシェーダへ渡す構造体を見てみます。

struct v2f_surf
{
    UNITY_POSITION(pos);
    float2 pack0 : TEXCOORD0;
    float3 worldNormal : TEXCOORD1;
    float3 worldPos : TEXCOORD2;
    UNITY_SHADOW_COORDS(3)
    UNITY_FOG_COORDS(4)
    UNITY_VERTEX_INPUT_INSTANCE_ID
    UNITY_VERTEX_OUTPUT_STEREO
};

ライトマップや SH 関連の場合分けがなくなっていますね。

頂点シェーダ

v2f_surf vert_surf(appdata_full v)
{
    UNITY_SETUP_INSTANCE_ID(v);

    v2f_surf o;
    UNITY_INITIALIZE_OUTPUT(v2f_surf,o);
    UNITY_TRANSFER_INSTANCE_ID(v,o);
    UNITY_INITIALIZE_VERTEX_OUTPUT_STEREO(o);
    o.pos = UnityObjectToClipPos(v.vertex);
    o.pack0.xy = TRANSFORM_TEX(v.texcoord, _MainTex);
    o.worldPos = mul(unity_ObjectToWorld, v.vertex).xyz;
    o.worldNormal = UnityObjectToWorldNormal(v.normal);

    UNITY_TRANSFER_SHADOW(o,v.texcoord1.xy);
    UNITY_TRANSFER_FOG(o,o.pos);

    return o;
}

ベースパスの頂点シェーダからライトマップや SH 関連のコードがごっそりなくなってシンプルになっています。

フラグメントシェーダ

fixed4 frag_surf(v2f_surf IN) : SV_Target
{
    UNITY_SETUP_INSTANCE_ID(IN);

    Input surfIN;
    UNITY_INITIALIZE_OUTPUT(Input,surfIN);
    surfIN.uv_MainTex.x = 1.0;
    surfIN.uv_MainTex = IN.pack0.xy;

    float3 worldPos = IN.worldPos;
    float3 worldViewDir = normalize(UnityWorldSpaceViewDir(worldPos));
#ifndef USING_DIRECTIONAL_LIGHT
    fixed3 lightDir = normalize(UnityWorldSpaceLightDir(worldPos));
#else
    fixed3 lightDir = _WorldSpaceLightPos0.xyz;
#endif

    SurfaceOutputStandard o;
    UNITY_INITIALIZE_OUTPUT(SurfaceOutputStandard, o);
    o.Albedo = 0.0;
    o.Emission = 0.0;
    o.Alpha = 0.0;
    o.Occlusion = 1.0;
    o.Normal = IN.worldNormal;

    surf(surfIN, o);

    UNITY_LIGHT_ATTENUATION(atten, IN, worldPos)

    fixed4 c = 0;

    UnityGI gi;
    UNITY_INITIALIZE_OUTPUT(UnityGI, gi);
    gi.indirect.diffuse = 0;
    gi.indirect.specular = 0;
    gi.light.color = _LightColor0.rgb;
    gi.light.dir = lightDir;
    gi.light.color *= atten;

    c += LightingStandard(o, worldViewDir, gi);
    c.a = 0.0;

    UNITY_APPLY_FOG(IN.fogCoord, c);
    UNITY_OPAQUE_ALPHA(c.a);

    return c;
}

分岐はディレクショナルかそうでないかでライトの方向を変えているだけです。UNITY_LIGHT_ATTENUATION() ではポイントライトでは距離減衰を計算しています。また、もう一つ注目する点としては、UnityGIInputLightingStandard_GI が消えています。これらは先に見たようにライトマップやリフレクションプローブからの情報を計算しているのですが、加算パスではこれらは必要ないためです。結果として、UnityGI に入れる情報もシンプルになっています。UNITY_APPLY_FOG() では先にも見たように加算パスではフォグの色ではなく黒と合成して薄める形になります。

結果を見てみましょう。まずはベースパスの出力を 0 にして加算パス分だけ見てみます。

f:id:hecomi:20180530104234p:plain

Pixel Light Count で指定した数だけのライト分の加算パスが走り合成されています。ではベースパスとブレンドした結果を見てみます。ブレンドは光なので Blend One One の加算合成になります。

f:id:hecomi:20180530104348p:plain

こうして最終的な画が得られました!解説は以上になります、お疲れ様でした。

おわりに

冒頭で深く掘り下げると言いましたが、複雑に見えるのはマクロの展開が深かったり、場合分けがいつ行われるのか分からなかったりといったことが原因で、基本的にはライティングに必要な情報をいろいろな場所から集めてきて、それをルールに基づいてライティングを行っている形です。それを更に掘り下げて理論についても考え始めると大変ですが(私も全然理解していませんが)、機能単位(ライトマップ、リアルタイムGI、ライトプローブ、リフレクションプローブなど)を把握していれば、どこで何が行われているのか、ということを理解できるのではないでしょうか。これを理解するだけでも、自分の好みの絵作りをしたいときに役に立つよう使うことは可能だと思います。ということを示せるよう、どこかでトゥーンシェーディングに PBR の結果をブレンディングする内容を書いてみたいと思っています。

最後になりましたが、本記事は深い理解をもとに書いたものではなく、シェーダを読み調べながら書いた記事なりますので、誤りを含む可能性があります。見つけられた際はご指摘いただけますと加筆・修正いたしますのでお願いいたします。

参考 / より詳しく知りたい方向け

barkingmousestudio.com

www.shadercat.com

catlikecoding.com

*1:例えば簡単にするために法線マッピングに必要な情報などはゴソッと消しています

*2:このフラグは UnityStandardConfig.cginc で (LIGHTMAP_ON && LIGHTPROBE_SH) として定義されていますが、具体的にどういう条件下でこの 2 つのフラグが同時に立つかは不明でした...