Unity 2018.1 より提供される Scriptable Render Pipeline について調べてみた

はじめに

Unity 2018.1b より Scriptable Render Pipeline (SRP) というレンダリングパイプラインをより細かくコントロールできる機能がリリースされました。SRP の登場により、従来の Forward / Deferred に加え、新たに Compute Shader を活用し SSS などのマテリアルにも対応したハイエンド向け高画質パイプライン(HDRP)と、シングルパスでライトの計算も可能なモバイルや VR において有力な軽量パイプライン(LWRP)が公式の実装として提供されることになりました。さらに、これらはアセットとして提供されるため、我々の手でもカスタマイズ可能で、また一から自分で作成することも出来ます。
本エントリではこの SRP について、新しく追加されたパイプラインをサラッと紹介するのと、公式で公開されているシンプルなサンプルを読み解いて勘所を掴むことを目的として解説を行いたいと思います。

環境

  • Unity 2018.1.0b6
  • macOS Sierra 10.12.6 / Windows 10

公式による資料

既に多くの資料やコードが公式より公開されています。
公式による SRP の Pros/Cons 説明の動画が以下に上がっています。
昨年の Unite 2017 Tokyo でも講演がありました。
Unity 5.6 および 2017.x の時代からも Scriptable Render Loop というリポジトリ名で experimental な機能として GitHub で公開されていました(なのでタイトルは完全には正しくありません...)。今は Loop を Pipeline と名前を変え、同じリポジトリでコードが公開されています。
また、Adam や Blacksmith といったデモを作ったチームが Unity 2018 を使って作成した最新作の Book of the Dead にも活かされているようです。

SRP が必要な理由

従来の Unity では Forward または Deferred という用意された Render Pipeline のみを使用する形になっていました。簡単に説明すると、以下のような形です。

Forward

Forward は、オブジェクトごとのレンダリングのタイミングで一度にマテリアルの設定や主要なライティング等の計算を行って、その結果をカラーバッファ出力する方式です。グラフィクスメモリの容量も少なく済み、オブジェクトごとに表現を変えられるメリットがある一方、余計なピクセルの描画が多かったり、多量のライトがあると重くなったりといったデメリットもあります。VR やモバイルではこちらが使われていることが多いです。

Deferred

Deferred は、オブジェクトごとのレンダリングではアルベドやデプス、法線、その他マテリアルの情報を G-Buffer と呼ばれる複数のテクスチャバッファに書き込んでおき、後でまとめてそれらの情報を使ってライティングやシェーディングを行うという方式です。シェーディングやライティングをまとめて行うため、ライトを数多く使っても(ライトのサイズが小さい限り)大きな問題にはならず、また余分な描画を抑えられたり、一貫性のあるライティングを行ったりすることが可能です。一方、グラフィクスメモリの消費が大きかったり、オブジェクト固有の半透明の描画が出来ない(Forward とハイブリッドにする)といったデメリットもあります。コンソールゲームなど見た目がリアルなゲームではこちらが使われていることが多いです。

カスタマイズ性の問題

2017 以前の Unity では、上記パイプラインのいずれかを選んだ後できるカスタマイズ方法としては、シェーダでマテリアル及びライトの描画を工夫したり、Command Buffer で事前に定義されたタイミングにフックして描画に手を入れたり、ポストプロセスで出力を加工したりといったものでした。
しかしながら Unity への要求としては、モバイル向けの 2D / 3D 描画、ハイエンドなコンソール向けの描画や VR 向けパフォーマンスを担保しつつリッチな描画、更には独自の工夫した独特な描画まで様々なものがあります。そしてこれらがマルチプラットフォームで動く必要もあります。それに対し、前者の実装はブラックボックスで、柔軟性も十分ではなく、パフォーマンス上の不利な面もあります。また、最新のレンダリング手法の採用も難しかったり、レンダリングのバグが有ったとしても修正することが出来なかったりします。

ゴール

そこで、C++ 側でレンダリングに必要な低レベルな機能(オブジェクト単位の操作、ソーティング、カリング、バッチングなど)を小さい単位でモジュールブロックとして実装し、そしてそれらを高レベルな API として C# 側に提供することで、C# 側でレンダリングループを制御できるようにしたものが SRP です。これにより、よりデバッグ性が高まり、拡張や修正が容易になり、また新しいレンダリング技法も採用しやすくなり、プラットフォーム毎に要らない機能を省いたり、スケジューリングを最適化することでパフォーマンスの改善も望めます。

新しいパイプライン

公式ブログで紹介されていたように、新たに 2 つのパイプラインが公式で実装されています。一つは Compute Shader を持つハイエンド用高画質パイプラインで、もう一つは Compute Shader を持たないモバイルなど向けの軽量パイプラインです。本章ではこれらを少し見ていきます。

ダウンロード

これらのパイプラインは現在は組み込みではなく、アセットの形で提供されています(むしろアセットの形に出来るのが強みだと思います)。ダウンロードは GitHub から行えます。
Unity 本体とのバージョンと強く結びついているため、それぞれのリリースにあったバージョンの Unity で動かす必要があります。master ブランチでは公式リリースされている Unity では動かない可能性があるとの説明もあるので、Release ページに上がっているバージョン(= タグが打ってあるもの)を利用するのが無難だと思われます。私は Unity 2018.1.0b6 向けのリリースを使用しました。
現状セットアップは少し面倒で、.unitypackage をクリックしてインポートして完了!とは行かないようです。次のように GitHub から持ってきます。
git clone https://github.com/Unity-Technologies/ScriptableRenderPipeline
cd ScriptableRenderPipeline
git checkout Unity-2018.1.0b6
git submodule update --init --recursive --remote
checkout するのは最新のタグ及び Unity のバージョンに合わせて調整して下さい。submodule では Post-processing スタックを持ってきています。全て終わったら新規に作成した Unity のプロジェクトの Assets ディレクトリ下に放り込んで下さい。これで準備完了です。

高画質パイプライン(HDRP)

HDRP は Shader Model 5.0 で動作するレンダラになります。物理ベースレンダリング、リニアライティング、HDR ライティングを念頭に置いたデザインです。Configurable Hybrid Tile/Cluster Deferred/Forward Lighting アーキテクチャと述べられてますが、詳細はちょっと勉強不足で説明できません...。Frame Debugger を見ると次のように色々なパスが増えています。
利用者視点としては、使用できるマテリアルが増えたりライトの種類が増えたりといった点が上げられます。マテリアルでは、異方性(見る方向によって物理特性が変わるもの、CD の裏面とか)、表面化散乱(Subsurface Scattering、肌みたいなもの)、クリアコート(UE4 と同じで表面に半透明層を持つもの?車の塗装みたいな)などが利用可能になる、とのことです。
ここでは良いサンプルと資料があったので SSS について見てみます。SampleScenes > HDTest > SSSProfiling を見てみます。人の顔が表示されるのですが、ここに SSS のマテリアルが適用されています。
SSS の効果は結構すごくて、SSS の代わりに Standard に変更してみると、特に拡大した際に差が歴然となります。
影部などを見てみるとボヤッとして皮膚下の散乱の表現度合いが増しています。これについては、以下の ADAM ep.3 の記事で解説されています。
記事中で述べられている SSS のプロファイルは次のように HDRP アセットから辿って設定できます。HDRP アセットを選択すると以下のように色々設定が出てきます。
この中の「Diffusion Profile Settings」をダブルクリックして Skin を表示すると設定を調整できます。
他にも色々と機能や調整項目があると思うのですが、このあたりは追うとかなり大変そうなので公式の解説を待つことにしましょう。

軽量パイプライン(LWRP)

LWRP はフォワードベースのレンダラで、最多で 8 個までのライトをオブジェクトごとにカリングを行い、シングルパスでシェーディングを行います。これまでの Forward レンダラでは ForwardBase パスに加え、1 つのライトに付き 1 つの ForwardAdd パスが実行されるマルチパスであることから、ライトを増やすとドローコールが増えてしまう欠点がありました。それがこの LWRP ではライトの数に制限があるものの少ないドローコールでより高速に描画が可能になっています。モバイルや VR に向いているとのことでした。
Frame Debugger を見てみると HDRP や従来のパイプラインと比べてもシンプルで分かりやすいです。
より細かい機能の比較は以下の Google Docs および公式ブログにまとまっています。

コードから理解する

冒頭の SRP 紹介の公式ブログでシンプルなデモプロジェクトが公開されています。
こちらを動かしながらコードを見ることで、SRP がどのようなことをやっているか理解する糸口になると思います。なお、未だ beta の機能であるため、API や仕様の変更が入り、本解説とは将来的に異なる可能性があることにご留意下さい。

SRP アセットの差し替え

プロジェクトを開くと次のような画面が表示されます。
Project Settings > Graphics の一番上に次のように「Scriptable Render Pipeline Settings」が追加されており、ここに使用したい SRP のアセットをセットすることが出来るようになっています。アセットを差し替えるとそれに対応して Tier Settings の項目も変わるようです。
このアセットは RenderPipelineAsset を継承したもので、ScriptableObject 派生になります。

画面をクリアするだけ

まずは「1-BasicAssetPipe」を試してみます。DemoScene を開いた上で、「Scriptable Render Pipeline Settings」に BasicAssetPipe アセットを設定して下さい。画面が次のようになります。
このアセットを記述しているコードを見てみます。
using UnityEngine;
using UnityEngine.Rendering;
using UnityEngine.Experimental.Rendering;

[ExecuteInEditMode]
public class BasicAssetPipe : RenderPipelineAsset
{
    public Color clearColor = Color.green;

#if UNITY_EDITOR
    [UnityEditor.MenuItem("SRP-Demo/01 - Create Basic Asset Pipeline")]
    static void CreateBasicAssetPipeline()
    {
        var instance = ScriptableObject.CreateInstance<BasicAssetPipe>();
        UnityEditor.AssetDatabase.CreateAsset(instance, "Assets/SRP-Demo/1-BasicAssetPipe/BasicAssetPipe.asset");
    }
#endif

    protected override IRenderPipeline InternalCreatePipeline()
    {
        return new BasicPipeInstance(clearColor);
    }
}

public class BasicPipeInstance : RenderPipeline
{
    private Color m_ClearColor = Color.black;

    public BasicPipeInstance(Color clearColor)
    {
        m_ClearColor = clearColor;
    }

    public override void Render(ScriptableRenderContext context, Camera[] cameras)
    {
        // does not so much yet :()
        base.Render(context, cameras);

        // clear buffers to the configured color
        var cmd = new CommandBuffer();
        cmd.ClearRenderTarget(true, true, m_ClearColor);
        context.ExecuteCommandBuffer(cmd);
        cmd.Release();
        context.Submit();
    }
}
コードはアセットとして保存される RenderPipelineAsset とレンダリング部を記述する RenderPipeline の 2 つのクラスからなっています。何故 2 つのクラスに分けたかの設計思想はコードからはちょっと読み取れなかったのですが、このような仕組みになっているようです。
まず、前者のアセット側を見てみます。前半部は ScriptableObject としてのアセット生成部なので省略します。
public class BasicAssetPipe : RenderPipelineAsset
{
    ...
    protected override IRenderPipeline InternalCreatePipeline()
    {
        return new BasicPipeInstance(clearColor);
    }
}
ここでは InternalCreatePipeline() を継承し、IRenderPipeline を継承するクラスをインスタンス化して返しています。おそらく public であるCreatePipeline() がエンジン側から適当なタイミングで呼び出され、その内部でこのオーバーライドした関数が呼び出されているのだと思います。
では、ここで返しているクラスはどんなものかというと...
public class BasicPipeInstance : RenderPipeline
{
    ...
    public override void Render(ScriptableRenderContext context, Camera[] cameras)
    {
        base.Render(context, cameras);
        
        var cmd = new CommandBuffer();
        cmd.ClearRenderTarget(true, true, m_ClearColor);
        context.ExecuteCommandBuffer(cmd);
        cmd.Release();
        
        context.Submit();
    }
}
このように Render() を継承して独自のループを書いています。まだこの段階ではあまり多くなく、CommandBuffer を作成し、ClearRenderTarget() でデプスおよびカラーバッファを指定した色でクリアしています。コマンドバッファの実行タイミングなどは、ScriptableRenderContext によって制御が可能で、これは他にもオブジェクトやスカイボックス、影の描画といったカスタムレンダーパイプラインの状態の保存とコマンドの実行を担うコンテキストになります。
なお、コマンドバッファについては以下の記事をご参照下さい。
この結果どういったコマンドが発行されているかを Frame Debugger から見てみましょう。
先ほど作成したコマンドバッファでクリアしている処理のみが行われています。他には一切処理されていません。以前の Unity では空のシーンであったとしても他にも色々な処理が行われていました。例えば空のプロジェクトを作成し、そのまま Frame Debugger を見てみると以下のようになります。
ユーザ側でレンダリングのタスクでやりたいことだけを全て C# から制御できるようになったわけですね。しかしながら未だバッファのクリアだけです。次にオブジェクトの描画を見ていきましょう。

不透明オブジェクトの描画

「Scriptable Render Pipeline Settings」に OpaqueAssetPipe アセットを設定して下さい。画面が次のように変わります。
オブジェクトが描画されました!コードの Render() 部はどうなっているのでしょうか。見ていきましょう。
...
public override void Render(ScriptableRenderContext context, Camera[] cameras)
{
    base.Render(context, cameras);

    // 全てのカメラに対して処理を行う
    foreach (var camera in cameras)
    {
        // カリング
        ScriptableCullingParameters cullingParams;
        if (!CullResults.GetCullingParameters(camera, out cullingParams))
        {
            continue;
        }
        var cull = CullResults.Cull(ref cullingParams, context);

        // カメラのセットアップ
        // レンダーターゲットや、ビュープロジェクション行列、
        // カメラ毎のビルトインシェーダのバリアントのセット等
        context.SetupCameraProperties(camera);

        // デプスバッファの削除
        var cmd = new CommandBuffer();
        cmd.ClearRenderTarget(true, false, Color.black);
        context.ExecuteCommandBuffer(cmd);
        cmd.Release();

        // 不透明オブジェクトの描画のための設定
        // BasicPass と名前のついたシェーダパスを使用する
        // ソート順はいくつかのオプションから組み合わせで指定可能
        // CommonOpaque はソーティングレイヤーやレンダーキューを考慮し前面から順番に書いてく方式
        // (不透明オブジェクトは基本的に前面から書いていくとオーバードローが発生しにくい)
        var settings = new DrawRendererSettings(camera, new ShaderPassName("BasicPass"));
        settings.sorting.flags = SortFlags.CommonOpaque;

        // どのオブジェクトを描画するかを記述
        // ここではレンダーキューのみ使用しているが、他にもレイヤが指定できる
        // レンダーキューが不透明の範囲(2000 ~ 2500)のオブジェクトが対象
        var filterSettings = new FilterRenderersSettings(true) 
        { 
            renderQueueRange = RenderQueueRange.opaque 
        };

        // カリングの結果見えている不透明オブジェクトを BasicPass パスで描画
        context.DrawRenderers(cull.visibleRenderers, ref settings, filterSettings);
        
        // スカイボックスは最後に描画(最背面なので最後が一番効率的)
        context.DrawSkybox(camera);

        // コンテキストの実行
        context.Submit();
    }
}
...
解説はコード中に書きましたが、色々と増えましたが、描画として実行しているのは DrawRenderers() で、この引数として必要な設定を色々用意している形になります。記述内容を見てみると、レンダリングそのものを記述するところは一切なく、大体が与えられたオプションからポチポチと選択して描画する形になっています。とてもシンプルですね!各設定の詳細は以下のリンクから見て下さい。
なお、コード中でも書きましたが BasicPass というシェーダパスを利用しています。この関係で、この名前の LightMode を持つシェーダパスを作成する必要があります。対応するシェーダの一部を抜粋すると以下のようにになります。
Shader "SRP/BasicPass"
{
    ...
    SubShader
    {
        ...
        Tags { "RenderType"="Opaque" }
        ...
        Pass
        {
            Tags { "LightMode" = "BasicPass" }
            ...
        }
    }
}
さて、この段階での Frame Debugger を見てみます。
見事にバッファクリア、各オブジェクトの描画、スカイボックス描画の 3 つのみで構成されています。更に、どうせカラーバッファは全て上書きされてしまうので、クリアしているのもデプスバッファのみです。また、オブジェクトは手前から順に描画されているのも1つずつ実行すると分かります。

半透明オブジェクト描画

最後は半透明なオブジェクトの描画です。「Scriptable Render Pipeline Settings」に TransparentAssetPipe アセットを設定して下さい。画面が次のように変わります。
半透明オブジェクトが追加されました。コードを見てみます。大半は先ほどの不透明オブジェクトと同じなので差分だけ示します。
public override void Render(ScriptableRenderContext context, Camera[] cameras)
{
    base.Render(context, cameras);

    foreach (var camera in cameras)
    {
        ...
        context.DrawSkybox(camera);

        // ここまで同じ

        // 設定は Opaque のものを使いまわす
        // ソート方法は Transparent に変更(不透明と逆で奥から描画になる)
        settings.sorting.flags = SortFlags.CommonTransparent;
        // レンダーキューの範囲は半透明(3000 - 3999?)
        filterSettings.renderQueueRange = RenderQueueRange.transparent;
        // 上記設定を使って描画
        context.DrawRenderers(cull.visibleRenderers, ref settings, filterSettings);

        context.Submit();
    }
}
新しく半透明の描画の部分が追加されました。先ほどはレンダーキューの範囲でマスクされて対象となっていなかったオブジェクトがここで描画されるようになります。Frame Debugger を見てみましょう。
二度目の RenderLoop が追加され、ここで半透明オブジェクトが描画されるようになります。今度は奥側から手前側へ順番に描画されます。
これらのコードを少し改造する例は、公式ブログに載っています。
シンプルな例のみでしたが、どういったことが制御できるかといった勘所がつかめたのではないでしょうか。ちなみに、LWRP のコードを覗いてみると 1400 行くらいあります(笑)。

おわりに

なかなか自分で SRP を使ってレンダリングパイプラインをいじる機会はないかもしれませんが、ちょっとしたバグに当たった時の修正や、パフォーマンス改善のために不要なパスを省いたり、既存のものに少し何か特殊なものを追加したいなどというユースケースに対して、本記事で紹介した知識があると役に立つかもしれません。また勉強の土台としてもとても便利です。
一方で、これまでのゲームの差し替えを行う場合、特に様々なカスタムシェーダを利用した Forward ベースで作成しているゲームを移植する、みたいになってくるとちょっと大変かもしれません(対応するシェーダパスが実装されてない可能性があるので)。ただ、Unity 側で予め HDRP や LWRP に変換してくれる仕組みを用意してくれる可能性もあると思います(サンプルをプロジェクトに入れるとマテリアルの HDRP へのアップグレード機能はすでにあるようです)。
しかしながら選択肢も広がり、パフォーマンスの調整もしやすくなり、今後、様々なレンダリングパイプラインの選択肢がアセットとして提供される可能性もあり、とても楽しみですね。私も機会があれば何かちょっと特殊なパイプラインに挑戦してみたいです。