BLOG

ゲーム内でユーザーにオブジェクトを選択させるなどで、アウトラインをつけて強調表示したいというシーンはよくありますよね。

次のようなキャラクターについている緑の線のこんな感じのやつです。

こんな感じのやつです

もともとこの記事を参考にさせて頂いていました。(大変すばらしい記事で本当に助かりました。著者の@Arihiさん、本当にありがとうございます!)

ただ、僕の場合はプロジェクトを従来のBuiltin Render Pipeline(BRP)からUniversal Render Pipeline(URP)に変更する必要があり、そこでこの方法をアップデートする必要が発生してしまったのでこのとき行ったことを備忘録として記事にしておきます。

※個人的にも現在絶賛URPへの移植中で理解が不完全なので、修正すべき点が盛りだくさんの可能性が高いです。改善点あったら是非教えて下さい!

概要

BRPではカメラに対してCommandBufferを挿入することで描画結果を取得・合成していたのですが、URPというかScriptable Render Pipelineはそういうことをより簡単に・高パフォーマンスに実行するために設計されたものであるので、もちろんこれと違った実装を行う必要があります。

かといってすべてが全く違うかというとそういうわけでもなく、枠組みだけを移し替えるだけで大抵のことは上手くいくようです。

登場人物は主に3つ

Universal Render Pipeline Asset_Renderer

Unityで用意されているシステムに関連するアセット 自分で作ったカスタムなレンダラー(Render Feature)を登録し、まとめるアセット。ここに自作のクラスを登録しておくだけで、描画時にインスタンスを生成し実行してくれる。

Scriptable Renderer Feature

クラス名。自作のRender Featureはこのクラスを継承して作成することができる。 Render Featureでは実際の描画内容を記述しない。内部にScriptableRenderPass(後述)を複数持ち、これらをセットアップし呼び出すための中間的な立ち位置となる。

override必須の関数は次の2つ。

  • void Create()

ほぼコンストラクターの代わりと言える。インスタンス生成時に呼ばれる。

  • void AddRenderPasses(ScriptableRenderer, ref RenderingData)

ほぼUpdate()の代わり。毎描画時に呼ばれる。

ScriptableRenderer.EnqueuePass(ScriptableRenderPass)することで実際の描画を行うことが出来る。

ScriptableRenderPass

クラス名。いわゆるRenderPass的な立ち位置。実際にCommandBufferを作成し描画命令を登録するのはここ。

override必須の関数は1つ

  • Execute(ScriptableRenderContext, ref RenderingData) CommandBufferを取得し、ここのScriptableRenderContextに対してExecuteCommandBufferすることでカスタムな描画を行う。

重要なのは内部にrenderPassEventという変数があり、ここに代入するEnumによってパスが挿入されるタイミングをコントロールするということです。

このEnumはこの公式ドキュメントに見やすくまとめられています。なんか同じ公式ドキュメントでも、Enumの列挙順が描画順じゃなくてアルファベット順になっててクソ見にくいページがあったんですよね。

というかここまで書いておきながらアレなんですけど多分コード見たほうが早いです。

コード

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

public class OutlineRendererPass : ScriptableRenderPass {

    const string NAME = nameof(OutlineRendererPass);
    
    Material outlineMaterial = null;
    RenderTargetIdentifier renderTargetID = default;
    OutlineRenderer parentRendererFeature;
    
    public OutlineRendererPass(OutlineRenderer parentRendererFeature, Material outlineMaterial) {
        // ここでこのパスをどのタイミングで挿入するか決める
        renderPassEvent = RenderPassEvent.BeforeRenderingPostProcessing;
        this.parentRendererFeature = parentRendererFeature;
        this.outlineMaterial = outlineMaterial;
    }

    public void SetRenderTarget(RenderTargetIdentifier renderTargetID) {
        this.renderTargetID = renderTargetID;
    }
    
    public override void Execute(ScriptableRenderContext context, ref RenderingData renderingData) {

        // 一見いらないように見えるが、SceneView切替時などでリセットされることがあり、ないと画面がマゼンダのアレになる
        if (parentRendererFeature.contentRenderers == null) return;

        CommandBuffer CB = CommandBufferPool.Get(NAME);
        CameraData camData = renderingData.cameraData;

        int texId = Shader.PropertyToID("_MainTex");
        int w = camData.camera.scaledPixelWidth;
        int h = camData.camera.scaledPixelHeight;
        int shaderPass = 0;

        CB.GetTemporaryRT(texId, w, h, 0, FilterMode.Point, RenderTextureFormat.Default);
        CB.SetRenderTarget(texId);
        CB.ClearRenderTarget(false, true, Color.clear);

        // アウトラインを着けたいオブジェクトを描画
        foreach (Renderer renderer in parentRendererFeature.contentRenderers) {
            if (renderer == null) continue; // Play終了時に、Listは破棄されずその参照先のみ破棄されるのでScene描画のために入れとかないと例のマゼンダ(ry
            // オブジェクトのマテリアルすべてでかつForwardパスで描画
            for (int i = 0; i < renderer.sharedMaterials.Length; i++) {
                CB.DrawRenderer(renderer, renderer.sharedMaterials[i], i, 0);
            }
        }

        CB.Blit(texId, renderTargetID, outlineMaterial, shaderPass);
        
        context.ExecuteCommandBuffer(CB);
        CommandBufferPool.Release(CB);
    }
}

public class OutlineRenderer : ScriptableRendererFeature {

    // インスペクターで設定する アウトライン描画するマテリアル
    public Material outlineMaterial; 
    // アウトラインをつけたいオブジェクトのRendererのリスト
    public List<Renderer> contentRenderers;

    OutlineRendererPass outlineRenderPass = null;

    public override void Create() {
        if (outlineRenderPass == null) {
            outlineRenderPass = new OutlineRendererPass(this, outlineMaterial);
        }
    }

    public override void AddRenderPasses(ScriptableRenderer renderer, ref RenderingData renderingData) {
        outlineRenderPass.SetRenderTarget(renderer.cameraColorTarget);
        renderer.EnqueuePass(outlineRenderPass);
    }

}

アウトラインを描画するシェーダーもURP対応したものを一応掲載しておきます。

Shader "Unlit/silhouette" {
    Properties {
        _OutlineColor ("Outline Color", Color) = (1,1,1,1)
        _OutlineWidth ("Outline Width", Range(0, 10)) = 1
        _Cutoff ("Cutoff Level", Range(0, 1)) = 0
    }
    SubShader {
        Tags { "RenderType"="Overlay" "RenderPipeline"="UniversalRenderPipeline" }
        LOD 100
        ZWrite Off
        Blend SrcAlpha OneMinusSrcAlpha
        Cull Off
        ZTest Always

        Pass {
            HLSLPROGRAM
            #pragma vertex vert
            #pragma fragment frag

            #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"

			TEXTURE2D(_MainTex);
			SAMPLER(sampler_MainTex);
            half2 _MainTex_TexelSize;

            half4 _OutlineColor;
            half _OutlineWidth;

            half _Cutoff;

            struct Attributes {
                float4 positionOS : POSITION;
                float2 uv         : TEXCOORD0;
            };

            struct Varyings {
                float2 uv         : TEXCOORD0;
                float4 positionCS : SV_POSITION;
            };

            Varyings vert (Attributes input) {
                Varyings output;
                VertexPositionInputs vertexInput = GetVertexPositionInputs(input.positionOS.xyz);
                output.positionCS = vertexInput.positionCS;
                output.uv = input.uv;
                return output;
            }

            half4 frag (Varyings i) : SV_Target {

                half2 uv = i.uv;
                half2 destUV = _MainTex_TexelSize * _OutlineWidth;
                half4 col = SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, uv);

                half sum = 0;

                [unroll(3)]
                for (int i = -1; i <= 1; i++) {
                    [unroll(3)]
                    for (int j = -1; j <= 1; j++) {
                        sum += SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, uv + half2(destUV.x * i, destUV.y * j)).a;
                    }
                }

                sum = saturate(sum);
                clip(_Cutoff - col.a);

                half4 outline = sum * _OutlineColor;

                return outline;
            }
            ENDHLSL
        }
    }
}

多分もうURP使ったことある人なら何回もやったことある作業だと思うのでスクショなど掲載しないのですが、ファイルを作成したらRendererAssetのインスペクターの[Add Renderer Feature]ボタンから今回作成したRenderFeatureを選択し、さらに作成した中に今回の場合はマテリアルの設定があるのでそこにさっきのシェーダーをセットしたマテリアルをセットしてあげてください。

あ、あとハマりそうな点としては、例えば今回のスクリプトの場合、別のスクリプトから対象をとってPublic変数にアクセスすることになります。その対象のとり方が問題で、何ってRenderAssetに登録したこれはシーン上に存在しないのでいつもみたいにドラッグアンドドロップでインスペクターでセットできないんですよね。でも実際は簡単でインスペクターのフィールドのマルポツ選択の候補から選んであげれば良いだけです。僕は普段あんまり使わないので最初ちょっと戸惑ってしまったのですが。

注意する点

RenderFeatureはなんとSceneViewでのプレビューでも動作するのですが、これによって通常だとあまり想定しないオブジェクトの破棄などが発生してぬるれ(Null Reference Execption)が大量発生します(ガッ)。個人的には不本意なのですが適宜ヌルチェックを挿入しています。