読者です 読者をやめる 読者になる 読者になる

凹みTips

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

Unity 5 の CommandBuffer を利用したレンダリングパイプラインの拡張について調べてみた

Unity C# Shader

はじめに

Unity 5 より利用できるようになった CommandBuffer を利用するとレンダリングパイプラインを拡張して色々な表現が可能となります。公式ブログにこの詳細が書いてあり、サンプルもブログ中またはドキュメント上で配布されています。

他に CommandBuffer を使ったもので有名なものとしては、 id:i-saint さんの作品が挙げられると思います。

本エントリでは、Unity の公式ブログで紹介されている内容を一通り見て、CommandBuffer を利用すると何が出来るのか、どうやるのかということについてまとめたいと思います。

公式サンプルで紹介されている内容

Blurry Refractions

f:id:hecomi:20160206182056p:plain

Custom Deferred Lights

f:id:hecomi:20160206182139p:plain

Deferred Decals

f:id:hecomi:20160206182333p:plain

CommandBuffer ことはじめ

CommandBuffer とは?

グラフィクス関連で一般的に言われる CommandBuffer は、描画 API(e.g. glDrawElements()ID3D11DeviceContext::Draw())によって追加される GPU が理解できる形式のコマンドが格納されたリストを指し、低レベルなものなります(e.g. GPUレジスタ X の値を Y にする)。

Unity 5 でも最終的には同じことを行うのですが、API として利用できる CommandBuffer はもう少し高レベルなものを指し、例えばこのメッシュをあのマテリアルで描画する、みたいなものになります。

もう少し具体的には、例えば Blurry Refractions での利用方法を例にとってざっくり説明すると、Skybox 描画後に、現在までのレンダリング結果をコピーしてブラーを掛け、それをシェーダから参照できるように格納する、ということが抽象化された CommandBuffer の API によって行われています。そして Transparent な Queue(Skybox の描画よりも後)で描画されるマテリアルでこのブラーを掛けたレンダリング結果のテクスチャを参照して描画することで、すりガラス効果を表現しています。

利用できるタイミング

もちろん Skybox 描画後だけではなく、様々なタイミングで CommandBuffer を利用することが可能です。どういったタイミングかは公式ドキュメントに図が載っています。

f:id:hecomi:20160206190644p:plain

より詳細はドキュメントをご参照下さい。

Blurry Refractions

まずは、すりガラス効果のサンプルを見ていきましょう。

CommandBuffer を使わないと...?

内容を見て行く前に、CommandBuffer を利用しないとどうなるの?という点ですが、これは標準の Effect アセットに含まれる FX/Glass/Stained BumpDistort で確認できます。

f:id:hecomi:20160217184245p:plain

こちらは GrabPass という現在のレンダリング結果をテクスチャとして利用できるようになる特殊なパスを利用しています。

Shader "GrabPassInvert"
{
    SubShader
    {
        // 全ての不透明オブジェクト描画後に描画を行う
        Tags { "Queue" = "Transparent" }

        // オブジェクト背面の画を _GrabTexture へ保存
        GrabPass { }

        // _GrabTexture というテクスチャが使えるようになる
        Pass
        {
            SetTexture [_GrabTexture] { combine one-texture }
        }
    }
}

これを利用して FX/Glass/Stained BumpDistort では次のようにディストーションを適用しています。

half4 frag (v2f i) : SV_Target
{
    ...
    i.uvgrab.xy = (NormalMap を使ってディストーション計算);
    // ディストーションを考慮した座標のレンダリング結果
    half4 col = tex2Dproj(_GrabTexture, UNITY_PROJ_COORD(i.uvgrab));
    // ステンドグラスのテクスチャ
    half4 tint = tex2D(_MainTex, i.uvmain);
    // 合成
    col *= tint;
    ...
    return col;
}

こんな感じでレンダリング結果の _GrabTexture_MainTex を合成しています。式でお分かりいただけるように、そのままのレンダリング結果を利用しているため結構パッキリとした画になります。これを自然にブラーの掛けた画を利用したい!となると CommandBuffer を利用して、このレンダリング結果を加工する、という流れになるわけです。

CommandBuffer の作成と登録

では実際にスクリプトを見ていき、具体的にどうやって CommandBuffer を利用するのかを見ていきましょう。

f:id:hecomi:20160217182843p:plain

注目するオブジェクトはこの _RefractiveGlass で、ここに CommandBuffer を利用するコンポーネント CommandBufferBlurRefraction およびその結果を利用するシェーダを適用したマテリアルの GlassWithoutGrab が適用されています。そしてこのコンポーネントスクリプトを紐解けば、何をしているかが分かると思います。それでは見ていきましょう。分かりやすいように一部改変しています。

CommandBufferBlurRefraction.cs

using UnityEngine;
using UnityEngine.Rendering;
using System.Collections.Generic;

[ExecuteInEditMode]
public class CommandBufferBlurRefraction : MonoBehaviour
{
    public Shader m_BlurShader;
    private Material m_Material;

    private Camera m_Cam;

    // 全てのカメラに対して CommandBUffer を適用するために辞書型
    private Dictionary<Camera,CommandBuffer> m_Cameras = new Dictionary<Camera, CommandBuffer>();

    // 全ての CommandBUffer をクリア
    private void Cleanup()
    {
        foreach (var cam in m_Cameras) {
            if (cam.Key) {
                cam.Key.RemoveCommandBuffer(CameraEvent.AfterSkybox, cam.Value);
            }
        }
        m_Cameras.Clear();
        Object.DestroyImmediate(m_Material);
    }

    public void OnEnable()
    {
        Cleanup();
    }

    public void OnDisable()
    {
        Cleanup();
    }

    // OnWillRenderObject() はカメラごとに 1 度ずつ呼び出される
    // ここで Camera.current を全て登録してあげれば全てのカメラに対して処理できる
    public void OnWillRenderObject()
    {
        // --- ここからは初期化 ---

        // 有効でない場合はクリーン
        if (!gameObject.activeInHierarchy || !enabled);
            Cleanup();
            return;
        }

        // 現在のカメラを取得
        var cam = Camera.current;
        if (!cam) return;

        // 既に CommandBuffer を適用済みなら何もしない
        if (m_Cameras.ContainsKey(cam)) return;

        // マテリアルの初期化
        // m_BlurShader は後述のブラーを適用するシェーダ
        if (!m_Material) {
            m_Material = new Material(m_BlurShader);
            m_Material.hideFlags = HideFlags.HideAndDontSave;
        }

        // --- ここから CommandBuffer の構築 ---

        // CommandBuffer の生成と登録
        var buf = new CommandBuffer();
        buf.name = "Grab screen and blur";
        m_Cameras[cam] = buf;

        // 現在のレンダリング結果をカメラと同じ解像度の一時的な Render Texture へコピー
        int screenCopyID = Shader.PropertyToID("_ScreenCopyTexture");
        buf.GetTemporaryRT(screenCopyID, -1, -1, 0, FilterMode.Bilinear);
        buf.Blit(BuiltinRenderTextureType.CurrentActive, screenCopyID);

        // 半分の解像度で更に 2 枚の Render Texture を生成
        int blurredID = Shader.PropertyToID("_Temp1");
        int blurredID2 = Shader.PropertyToID("_Temp2");
        buf.GetTemporaryRT(blurredID, -2, -2, 0, FilterMode.Bilinear);
        buf.GetTemporaryRT(blurredID2, -2, -2, 0, FilterMode.Bilinear);

        // 半分の解像度へスケールダウンしてコピー
        buf.Blit(screenCopyID, blurredID);

        // フル解像度の Render Target はもう利用しないので解放
        buf.ReleaseTemporaryRT(screenCopyID);

        // 横方向のブラー
        buf.SetGlobalVector("offsets", new Vector4(2.0f/Screen.width, 0, 0, 0));
        buf.Blit(blurredID, blurredID2, m_Material);

        // 縦方向のブラー
        buf.SetGlobalVector("offsets", new Vector4(0, 2.0f/Screen.height, 0, 0));
        buf.Blit(blurredID2, blurredID, m_Material);

        // オフセットを調整して再度横方向のブラー
        buf.SetGlobalVector("offsets", new Vector4(4.0f/Screen.width, 0, 0, 0));
        buf.Blit(blurredID, blurredID2, m_Material);

        // 同じく縦方向のブラー
        buf.SetGlobalVector("offsets", new Vector4(0, 4.0f/Screen.height, 0, 0));
        buf.Blit(blurredID2, blurredID, m_Material);

        // 結果を _GrabBlurTexture へ格納
        buf.SetGlobalTexture("_GrabBlurTexture", blurredID);

        // --- CommandBuffer の構築完了 ---

        // という一連の流れを記録した CommandBuffer をスカイボックス描画後のタイミングで
        // 適用するように現在のカメラへと登録する
        cam.AddCommandBuffer(CameraEvent.AfterSkybox, buf);
    }
}

いかがでしょうか。重要なところだけ抜粋して見てみます。まず、以下のように CommandBuffer を作成します。

var buf = new CommandBuffer();

次に、現在のレンダリング結果をテンポラリな Render Texture へとコピーしています。

buf.GetTemporaryRT(screenCopyID, -1, -1, 0, FilterMode.Bilinear);
buf.Blit(BuiltinRenderTextureType.CurrentActive, screenCopyID);

そしてスケールダウンして何度もブラーをかけています。

buf.Blit(screenCopyID, blurredID);

buf.SetGlobalVector("offsets", new Vector4(2.0f/Screen.width, 0, 0, 0));
buf.Blit(blurredID, blurredID2, m_Material);

buf.SetGlobalVector("offsets", new Vector4(0, 2.0f/Screen.height, 0, 0));
buf.Blit(blurredID2, blurredID, m_Material);

...

そしてこのブラーをかけた Render Texture をマテリアルから参照できるように登録しています。

buf.SetGlobalTexture("_GrabBlurTexture", blurredID);

最後に、この CommandBuffer をカメラへ登録して完了です。

cam.AddCommandBuffer(CameraEvent.AfterSkybox, buf);

面白いのは、処理がこれら API を呼び出したタイミングで実行されているわけではなく、一連の操作のリストとして CommandBuffer に記録されている点です。公式ブログでは、これを「“list of things to do” buffers」と表現していました。つまり、登録したタイミングでは実行されずに、指定したタイミングで遅延実行されるわけです。その指定したタイミング、というのがここで指定している CameraEvent.AfterSkybox、つまりスカイボックス描画後、というわけです。

素晴らしいことに、この一連の処理は Frame Debugger から1つずつ見ることが出来ます。その様子がこちらになります。

f:id:hecomi:20160217202138g:plain

レンダリング結果を取得、ブラーを繰り返しかけ、それを Transparent のパスですりガラス板のテクスチャとして利用して最終的な画ができているのが分かると思います。ブラーのシェーダ(SeparableBlur.shader)の詳細は割愛しますが、周囲のピクセルの重み付け平均を取る簡単な実装になっています。

マテリアルで描画

ではこうして登録されたテクスチャをどうやって描画しているか、シェーダを見てみます。

GlassWithoutGrab.shader

...
sampler2D _GrabBlurTexture;
...

half4 frag (v2f i) : SV_Target
{
    ...
    i.uvgrab.xy = (法線マップからディストーションを計算);
    half4 col = tex2Dproj(_GrabBlurTexture, UNITY_PROJ_COORD(i.uvgrab));
    half4 tint = tex2D(_MainTex, i.uvmain);
    col = lerp(col, tint, _TintAmt);
    ...
    return col;
}
...

Standard Assets のものとほぼ同じコードで、利用しているテクスチャが GrabPass で得たものでなく、先ほど登録した _GrabBlurTexture を利用しています。これによってキャプチャした画をそのまま透かすのではなく、ブラーを掛けて透かす、ということが実現できるわけですね。

ちなみに、カラーの計算だけちょっと違うので、同じにする(col *= tint;)とこんな感じになります。

f:id:hecomi:20160219033012p:plain

CommandBuffer 関連の API

出てきた API をざっと見てみます。

  • Camera.AddCommandBuffer()
    • CommandBuffer を追加、構築した最後のタイミングで適用したいカメラに対して実行
  • Camera.RemoveCommandBuffer()
    • CommandBuffer を削除、破棄時に実行
  • CommandBuffer.GetTemporaryRT()
    • テンポラリな RenderTexture を生成
    • Shader.PropertyToID()string から int の ID を生成する
      • この string の文字列はそのままシェーダ内で sampler2D のテクスチャとして参照可能
    • 引数の width および height に負の値を与えると 1 / x のサイズになる
  • CommandBuffer.ReleaseTemporaryRT()
    • RenderTexture を解放
  • CommandBuffer.Blit()
    • 指定したマテリアルで第 1 引数の RenderTexture を第 2 引数の RenderTexture へ転送
    • Image Effect の Graphics.Blit() のようにいくつかオーバーロードがある
  • CommandBuffer.SetGlobalXXX()
    • Image Effect ではマテリアルに対して Material.SetXXX() を行うが、CommandBuffer は遅延実行されるため CommandBuffer に対してマテリアルに与える変数を格納しておく

その他

Readme にも書いてありますが、現在のところ Scene ビューではプレビューが行われません。また、パフォーマンスに関しても全ての本エフェクトを適用したオブジェクトで Grab を行ったりするため、実用する上ではもう少し工夫が必要です。

Custom Deferred Lights

では次に Custom Deferred Lights を見ていきましょう。CommandBuffer を使ったコードの書き方の概要は先ほど見たので、ここからは要点の解説にしたいと思います。

Command Buffer を使わないと...?

ちなみにですがジリジリ感のあるライトは CommandBuffer によるものではありません。あくまで Standard シェーダによる効果です。シーンには RGB のライトがそれぞれ 3 個ずつ、計 9 個配置されているのですが、右のものは通常の点光源(Point Light)で、左と中央のものが CommandBuffer を利用して追加されたカスタムライトになります。

f:id:hecomi:20160218171006p:plain

新しく Sphere LightTube Light というライトが追加されています。Sphere Light は点光源とあまり変わらないですが、Tube Light の方は細長い形状のライトができていて面白いですね。ちなみに紛らわしいですが、Custom Lighting はライトを受ける側がどのように受けるかを記述するもので、こちらはライトそのものを記述する形になります。

概要

カスタムライトは Deferred のレンダリングパイプラインの Lighting Pass の直後に CommandBuffer を実行しています。これによって通常のプロセスで作成された G-Buffer をシェーダの中で _CameraGBufferTexture0RGB: 拡散色)、_CameraGBufferTexture1RGB: スペキュラ、A: ラフネス)、_CameraGBufferTexture2RGB: 法線)を利用できます。またレンダーターゲットは Light Buffer(_CameraGBufferTexture3)になっています。ここに直接ライティングをゴリゴリ記述していく形になります(詳細を理解するにはライティングの知識が必要です...)。

各カスタムライトの情報は別途スクリプトCustomLight.cs)を作成しておき、その Transform や色情報、明度や範囲を保存しておきます。

f:id:hecomi:20160218172206p:plain

そして CommandBuffer ではこの情報を収集して全てのカスタムライトに対してライティングを行うわけですが、Light Buffer へ書き込む際は、通常の描画同様、メッシュとマテリアルが必要になります。そこで、CommandBuffer.DrawMesh() を利用してライティングの影響範囲(カスタムライトに持たせた範囲のパラメタ)を元に球のメッシュを生成し、その球をライティングを計算する CustomLightShader.shader を使って描画します。この際、球は DepthTest を Off(ZTest Always)にして描画します。が、実際には球が描画されるのではなく、球が描画される範囲にライティングの結果が焼きこまれる、という感じになります。

このサンプルではおまけとして、ライトの形状(球やチューブ)を BeforeForwardAlpha のタイミングで描画しています。

カスタムライトの情報を作成

さて、ではカスタムライトの情報を登録するスクリプトを見てみます。何てことはなく、各パラメタを public で出しつつ、Gizmo を描画しているだけです。ちなみに登録先の CustomLightSystemCustomLightRenderer.cs に定義されている HashSet をシングルトンにするためのラッパーです。

CustomLight.cs

using UnityEngine;

[ExecuteInEditMode]
public class CustomLight : MonoBehaviour
{
    public enum Kind
    {
        Sphere,
        Tube
    }
    public Kind m_Kind;

    public Color m_Color      = Color.white;
    public float m_Intensity  = 1.0f;
    public float m_Range      = 10.0f;
    public float m_Size       = 0.5f;
    public float m_TubeLength = 1.0f;

    public void OnEnable()
    {
        // このライトを登録
        CustomLightSystem.instance.Add(this);
    }

    public void Start()
    {
        // このライトを登録
        CustomLightSystem.instance.Add(this);
    }

    public void OnDisable()
    {
        // このライトを削除
        CustomLightSystem.instance.Remove(this);
    }

    // カラースペースをリニアにする
    public Color GetLinearColor()
    {
        return new Color(
            Mathf.GammaToLinearSpace(m_Color.r * m_Intensity),
            Mathf.GammaToLinearSpace(m_Color.g * m_Intensity),
            Mathf.GammaToLinearSpace(m_Color.b * m_Intensity),
            1.0f
        );
    }

    public void OnDrawGizmos()
    {
        // 選択されていない時はライトアイコンを表示
        ...
    }

    public void OnDrawGizmosSelected()
    {
        // 選択されている時はライトの影響範囲も表示
        ...
    }
}

CommandBuffer の処理

こうして設定した情報を CommandBuffer に与えます。

using UnityEngine;
using UnityEngine.Rendering;
using System.Collections;
using System.Collections.Generic;

[ExecuteInEditMode]
public class CustomLightRenderer : MonoBehaviour
{
    ...

    public Mesh m_CubeMesh;
    public Mesh m_SphereMesh;

    // CommandBuffer はライティング用、ライトのメッシュを表示用で 2 つ
    private struct CmdBufferEntry
    {
        public CommandBuffer m_AfterLighting;
        public CommandBuffer m_BeforeAlpha;
    }
    private Dictionary<Camera,CmdBufferEntry> m_Cameras = new Dictionary<Camera,CmdBufferEntry>();

    ...

    public void OnWillRenderObject()
    {
        ...

        CmdBufferEntry buf = new CmdBufferEntry();
        if (m_Cameras.ContainsKey(cam))
        {
            // use existing command buffers: clear them
            buf = m_Cameras[cam];
            buf.m_AfterLighting.Clear ();
            buf.m_BeforeAlpha.Clear ();
        }
        else
        {
            // create new command buffers
            buf.m_AfterLighting = new CommandBuffer();
            buf.m_AfterLighting.name = "Deferred custom lights";
            buf.m_BeforeAlpha = new CommandBuffer();
            buf.m_BeforeAlpha.name = "Draw light shapes";
            m_Cameras[cam] = buf;

            cam.AddCommandBuffer (CameraEvent.AfterLighting, buf.m_AfterLighting);
            cam.AddCommandBuffer (CameraEvent.BeforeForwardAlpha, buf.m_BeforeAlpha);
        }

        // ライトの情報は Vector4 にパックする
        var propParams = Shader.PropertyToID("_CustomLightParams");
        var propColor = Shader.PropertyToID("_CustomLightColor");
        Vector4 param = Vector4.zero;
        Matrix4x4 trs = Matrix4x4.identity;

        // 登録された全てのライトを描画する
        var system = CustomLightSystem.instance;
        foreach (var o in system.m_Lights)
        {
            // パラメタをパックしてマテリアルに渡す
            param.x = o.m_TubeLength;
            param.y = o.m_Size;
            param.z = 1.0f / (o.m_Range * o.m_Range);
            param.w = (float)o.m_Kind;
            buf.m_AfterLighting.SetGlobalVector(propParams, param);

            // ライトの色をマテリアルに渡す
            buf.m_AfterLighting.SetGlobalColor(propColor, o.GetLinearColor());

            // ライトの位置をマテリアルに渡す
            trs = Matrix4x4.TRS(o.transform.position, o.transform.rotation, new Vector3(o.m_Range*2,o.m_Range*2,o.m_Range*2));

            // ライトの影響範囲を球のメッシュとして描画
            // 球は球として描かれるのではなくライティングのマテリアル(Pass 0)を通じてライティングとして焼きこまれる
            buf.m_AfterLighting.DrawMesh (m_SphereMesh, trs, m_LightMaterial, 0, 0);
        }

        // 登録された全てのライトの形状を描画
        foreach (var o in system.m_Lights)
        {
            buf.m_BeforeAlpha.SetGlobalColor(propColor, o.GetLinearColor());

            // Sphere Light なら球を描画、Tube Light なら直方体を描画(マテリアルは Pass 1 を使う)
            if (o.m_Kind == CustomLight.Kind.Sphere)
            {
                trs = Matrix4x4.TRS(o.transform.position, o.transform.rotation, new Vector3(o.m_Size*2,o.m_Size*2,o.m_Size*2));
                buf.m_BeforeAlpha.DrawMesh(m_SphereMesh, trs, m_LightMaterial, 0, 1);
            }
            else if (o.m_Kind == CustomLight.Kind.Tube)
            {
                trs = Matrix4x4.TRS(o.transform.position, o.transform.rotation, new Vector3(o.m_TubeLength*2,o.m_Size*2,o.m_Size*2));
                buf.m_BeforeAlpha.DrawMesh(m_CubeMesh, trs, m_LightMaterial, 0, 1);
            }
        }       
    }
}

ライティングを計算

長いので重要な場所だけ抜粋します。やっていることは Deferred でやってきたパラメタ、各G-Buffer、与えられた光源パラメタを使い、最終的に BRDF で対象のピクセルからカメラ方向へどれだけ光がやってくるか求める UNITY_BRDF_PBS() マクロに各種パラメタを渡してライティングの結果を求めています。

Shader "CustomLights/PointArea" 
{

SubShader 
{

Tags { "Queue"="Transparent-1" }

CGINCLUDE
...

half4 CalculateLight (unity_v2f_deferred i)
{
    // 与えられた構造体からパラメタをアンパック
    float3 wpos;
    float2 uv;
    float atten, fadeDist;
    UnityLight light = (UnityLight)0; // UnityLight 構造体を 0 で初期化
    DeferredCalculateLightParams (i, wpos, uv, light.dir, atten, fadeDist);

    // G-Buffer を取得
    half4 gbuffer0 = tex2D (_CameraGBufferTexture0, uv);
    half4 gbuffer1 = tex2D (_CameraGBufferTexture1, uv);
    half4 gbuffer2 = tex2D (_CameraGBufferTexture2, uv);

    // G-Buffer の情報を展開
    light.color = _CustomLightColor.rgb * atten;
    half3 baseColor = gbuffer0.rgb;
    half3 specColor = gbuffer1.rgb;
    half3 normalWorld = gbuffer2.rgb * 2 - 1;
    normalWorld = normalize(normalWorld);
    half oneMinusRoughness = gbuffer1.a;
    half oneMinusReflectivity = 1 - SpecularStrength(specColor.rgb);

    // カメラから現在のピクセルへの方向
    float3 eyeVec = normalize(wpos-_WorldSpaceCameraPos);

    // 光源の中心位置
    float3 lightPos = float3(_Object2World[0][3], _Object2World[1][3], _Object2World[2][3]);

    // 光源の種類に応じて光源方向を計算する
    // Tube Light の場合の計算
    if (_CustomLightKind == 1)
    {
        float3 lightAxisX = normalize(float3(_Object2World[0][0], _Object2World[1][0], _Object2World[2][0]));
        float3 lightPos1 = lightPos + lightAxisX * _CustomLightLength;
        float3 lightPos2 = lightPos - lightAxisX * _CustomLightLength;
        // 計算の詳細は省略
        light.dir = CalcTubeLightToLight(wpos, lightPos1, lightPos2, eyeVec, normalWorld, _CustomLightSize);
    }
    // Sphere Light の場合の計算
    else
    {
        // 計算の詳細は省略
        light.dir = CalcSphereLightToLight(wpos, lightPos, eyeVec, normalWorld, _CustomLightSize);
    }
    light.ndotl = LambertTerm(normalWorld, light.dir);

    // BRDF でカメラ方向に対してどれだけ光が入ってくるかを計算
    UnityIndirect ind;
    UNITY_INITIALIZE_OUTPUT(UnityIndirect, ind);
    ind.diffuse = 0;
    ind.specular = 0;
    half4 res = UNITY_BRDF_PBS(baseColor, specColor, oneMinusReflectivity, oneMinusRoughness, normalWorld, -eyeVec, light, ind);

    return res;
}
ENDCG

Pass {
    Fog { Mode Off }
    ZWrite Off
    ZTest Always
    Blend One One
    Cull Front
    
    CGPROGRAM
    #pragma target 3.0
    #pragma vertex vert
    #pragma fragment frag
    #pragma exclude_renderers nomrt

    unity_v2f_deferred vert (float4 vertex : POSITION)
    {
        unity_v2f_deferred o;
        o.pos = mul(UNITY_MATRIX_MVP, vertex);
        o.uv = ComputeScreenPos (o.pos);
        o.ray = mul (UNITY_MATRIX_MV, vertex).xyz * float3(-1,-1,1);
        return o;
    }


    half4 frag (unity_v2f_deferred i) : SV_Target
    {
        return CalculateLight(i);
    }

    ENDCG
}

Pass {
    ... (メッシュを普通に描画)
}

}
Fallback Off
}

これを実行すると以下のようになります(最初の 3 つは通常のポイントライト)。

f:id:hecomi:20160219025446g:plain

その他

今回球は DepthTest を Off にして描画しましたが、これでは対象となるピクセルが多すぎるため重いです。より効率的にレンダリングを行うためには、Stencil Marking などもう少し工夫する必要があります。

Deferred Decals

最後に Deferred Decals です。

f:id:hecomi:20160219033422p:plain

仕組み

直接 G-Buffer をいじることで実現できます。法線の G-Buffer も書き換えることで落書きのようなデカールだけでなく、リアルな銃痕やマンホールといった凸凹のあるものまで描画できます。手法としてはライティングの時と同様、デカール用にメッシュを生成して、そのメッシュを使って拡散色と法線の G-Buffer を書き換えています。Readme に参考リンクとして以下のエントリが載っていますので併せて見てみると理解が深まると思います(特に2つ目のリンクのスライド)。

CommandBuffer の設定は Custom Deferred Lighting の時と大して変わりません。したがって、表面にピッタリと張り付いているような表現はシェーダ上でゴリゴリ計算することになります。CommandBuffer のタイミングはライティングの前に行ってやればライティングも貼ったデカールに対して反映されます。

サンプルでは、拡散色のみ貼り付け、法線情報のみ貼り付け、両方貼り付け、の3種類紹介されています。やっていることは同じで、それぞれ書き込む先の G-Buffer が異なるだけです。

CommandBuffer

まずはデカールするキューブに Decal コンポーネントを付与します。

Decal.cs

using UnityEngine;

[ExecuteInEditMode]
public class Decal : MonoBehaviour
{
    public enum Kind
    {
        DiffuseOnly,
        NormalsOnly,
        Both
    }
    public Kind m_Kind;
    public Material m_Material;

    public void OnEnable()
    {
        DeferredDecalSystem.instance.AddDecal(this);
    }

    public void Start()
    {
        DeferredDecalSystem.instance.AddDecal(this);
    }

    public void OnDisable()
    {
        DeferredDecalSystem.instance.RemoveDecal(this);
    }

    private void DrawGizmo(bool selected)
    {
        ...(キューブを描画、選択中は濃く表示)
    }

    public void OnDrawGizmos()
    {
        DrawGizmo(false);
    }
    public void OnDrawGizmosSelected()
    {
        DrawGizmo(true);
    }
}

デカールの種類とどんなマテリアル(貼り付けるテクスチャ)かが情報として格納されています。あとは CustomLight と同じように、HashSet の static なラッパーである DeferredDecalSystem へ登録・削除するコードが書かれています。

では CommandBuffer の構築のコードを見てみましょう。

using UnityEngine;
using UnityEngine.Rendering;
using System.Collections;
using System.Collections.Generic;

...

[ExecuteInEditMode]
public class DeferredDecalRenderer : MonoBehaviour
{
    public Mesh m_CubeMesh;
    private Dictionary<Camera, CommandBuffer> m_Cameras = new Dictionary<Camera, CommandBuffer>();

    ...

    public void OnWillRenderObject()
    {
        ...

        CommandBuffer buf = null;
        if (m_Cameras.ContainsKey(cam))
        {
            // 本来ならば変更があった時のみ作りなおすべき
            buf = m_Cameras[cam];
            buf.Clear();
        }
        else
        {
            buf = new CommandBuffer();
            buf.name = "Deferred decals";
            m_Cameras[cam] = buf;
            cam.AddCommandBuffer(CameraEvent.BeforeLighting, buf);
        }

        // 後で利用する CommandBuffer.DrawMesh() に渡されるマテリアルのシェーダ内で
        // _NormalsCopy という変数名で GBuffer2(法線/ラフネスマップ)にアクセスできるようにする
        var normalsID = Shader.PropertyToID("_NormalsCopy");
        buf.GetTemporaryRT(normalsID, -1, -1);
        buf.Blit(BuiltinRenderTextureType.GBuffer2, normalsID);

        var system = DeferredDecalSystem.instance;

        // 拡散色のみのデカールを描画
        buf.SetRenderTarget(BuiltinRenderTextureType.GBuffer0, BuiltinRenderTextureType.CameraTarget);
        foreach (var decal in system.m_DecalsDiffuse)
        {
            buf.DrawMesh(m_CubeMesh, decal.transform.localToWorldMatrix, decal.m_Material);
        }

        // 法線情報のみのデカールを描画
        buf.SetRenderTarget (BuiltinRenderTextureType.GBuffer2, BuiltinRenderTextureType.CameraTarget);
        foreach (var decal in system.m_DecalsNormals)
        {
            buf.DrawMesh(m_CubeMesh, decal.transform.localToWorldMatrix, decal.m_Material);
        }

        // 両方適用するデカールを描画
        RenderTargetIdentifier[] mrt = {BuiltinRenderTextureType.GBuffer0, BuiltinRenderTextureType.GBuffer2};
        buf.SetRenderTarget (mrt, BuiltinRenderTextureType.CameraTarget);
        foreach (var decal in system.m_DecalsBoth)
        {
            buf.DrawMesh(m_CubeMesh, decal.transform.localToWorldMatrix, decal.m_Material);
        }

        buf.ReleaseTemporaryRT(normalsID);
    }
}

BeforeLighting のタイミングでそれぞれのデカールのパターンを走査して貼り付けています。この貼り付ける CommandBuffer.DrawMesh() のタイミングで、次に紹介するシェーダを利用してペタリと地面にデカールが貼り付けられます。

シェーダ

デカール用キューブを使って交わった面に対してプロジェクションするような処理は以下のように実現しています。ライティングのコードと結構共通する所があります。ここでは拡散色用のデカールのコードだけ見てみます(他もだいたい同じです)。

Shader "Decal/DecalShader"
{

Properties
{
    _MainTex ("Diffuse", 2D) = "white" {}
}

SubShader
{

Pass
{
    Fog { Mode Off } // no fog in g-buffers pass
    ZWrite Off
    Blend SrcAlpha OneMinusSrcAlpha

    CGPROGRAM
    #pragma target 3.0
    #pragma vertex vert
    #pragma fragment frag
    #pragma exclude_renderers nomrt
    
    #include "UnityCG.cginc"

    struct v2f
    {
        float4 pos         : SV_POSITION;
        half2  uv          : TEXCOORD0;
        float4 screenUV    : TEXCOORD1;
        float3 ray         : TEXCOORD2;
        half3  orientation : TEXCOORD3;
    };

    v2f vert (float3 v : POSITION)
    {
        v2f o;
        o.pos = mul(UNITY_MATRIX_MVP, float4(v, 1));

        // デカール用キューブの横奥方向を UV として利用
        // (が、実際は貼り付けた場所から UV を再計算するため使わない)
        o.uv = v.xz + 0.5;

        // スクリーン上の UV
        o.screenUV = ComputeScreenPos(o.pos);

        // XZ 平面
        o.ray = mul(UNITY_MATRIX_MV, float4(v, 1)).xyz * float3(-1, -1, 1);

        // 立方体の上方向
        o.orientation = mul((float3x3)_Object2World, float3(0, 1, 0));

        return o;
    }

    CBUFFER_START(UnityPerCamera2)
    float4x4 _CameraToWorld;
    CBUFFER_END

    sampler2D _MainTex;
    sampler2D_float _CameraDepthTexture;
    sampler2D _NormalsCopy;

    fixed4 frag(v2f i) : SV_Target
    {
        // Far Clip までの長さのレイにする
        i.ray = i.ray * (_ProjectionParams.z / i.ray.z);

        // --- (オブジェクト座標を渡ってきた情報から再構築する) ---

        // Depth を求める(お決まりの式)
        float2 uv = i.screenUV.xy / i.screenUV.w;
        float depth = SAMPLE_DEPTH_TEXTURE(_CameraDepthTexture, uv);

        // Depth を 0 ~ 1 にする(1 が Far Clip と思われる...)
        depth = Linear01Depth(depth);

        // レイと掛けあわせてぶつかった場所のカメラ座標を求める
        // (デカール用キューブでなく衝突面!)
        float4 vpos = float4(i.ray * depth, 1);

        // _CameraToWorld でカメラ座標をワールド座標へ変換
        float3 wpos = mul (_CameraToWorld, vpos).xyz;

        // _World2Object でオブジェクト座標へ変換
        float3 opos = mul (_World2Object, float4(wpos, 1)).xyz;

        // --- (ここまで) ---

        // デカール用キューブをレイが貫通していたら何もしない
        clip (float3(0.5, 0.5, 0.5) - abs(opos.xyz));

        // オブジェクト座標から UV を求めなおす
        i.uv = opos.xz + 0.5;

        // ワールド空間での法線方向を求める
        half3 normal = tex2D(_NormalsCopy, uv).rgb;
        fixed3 wnormal = normal.rgb * 2.0 - 1.0;

        // デカールを貼り付ける面がキューブの上方向と成す角が 72°以下くらいだったら表示
        // (ここでクリップしないと鉛直面とかにも貼り付けられる)
        clip(dot(wnormal, i.orientation) - 0.3);

        // 後は UV からテクスチャカラーを取り出しておわり
        fixed4 col = tex2D(_MainTex, i.uv);
        return col;
    }
    ENDCG
}        

}

Fallback Off
}

結構色々なテクニックを使って計算しています。気になる箇所を1つずつ変更しながら確認して理解していくと分かりやすいと思います。

最後に一連の処理を Frame Debugger で確認すると以下のようになっています。

f:id:hecomi:20160219031022g:plain

その他

このデカールの実装には弱点があり、現在の Unity の Deferred Shading では、Lightmap や、Ambient および Reflection Probe が G-Buffer の描画タイミングで行われることから、後から追記する本方式では対応できない点です。なのでライトをオフにすると見えなくなります。

f:id:hecomi:20160219022851p:plain

おわりに

いかがだったでしょうか。個別のシェーダの知識などは大変ですが、CommandBuffer を利用するととても簡単に Unity のレンダリングパイプラインに手を入れることが出来ます。もちろん描画負荷と相談しながらですが、かなり自由度高く且つ簡単に色々な表現が可能になる素晴らしい機能だと思います。