Zenn
🔆

【Unity】RenderGraphを使ったURPのカスタム例 ―Custom Bloom Passの作成―

2025/03/27に公開
2

株式会社PARTYのテクニカルアーティストの与羽です。今回の記事は、前から挑戦してみたかったUnityのUnivarsal Render Pipelineのカスタムに挑戦したいこうと思います。

今回取り上げるテーマは「Bloom」です。URPではPostProcessPassでBloomが実装されていますが、輝度が閾値以上に飽和した色しかBloomが適応されません。今回はカスタムのBloomPassを実装し、Bloomさせたい部分の色をコントロールできるようカスタムしていこうと思います。

image.png
トゥーンシェーダーに適したBloomを作りたいと思ったのがきっかけです。

最初にSRP(Scriptable Render Pipeline)やURPの概要を把握し、その後RenderPassとシェーダーの実装フローを追っていきましょう。記事の最後には応用例も紹介しています。

1. はじめに

1.1 SRPとは何か

Unityでは、Scriptable Render Pipeline(SRP)という仕組みを使って描画の仕組み全体の流れを自由にカスタマイズできます。

これによって、軽量でモバイルでも快適に動かせるようなものから、リッチでリアルなハイエンドな見た目まで、プラットフォームに合わせて調整できますし、特殊な表現を途中に入れたりと、自由にカスタマイズすることができます。

今回はこの仕組みを使って、Unityのレンダリングをカスタマイズし、Bloom表現をカスタマイズしていこうと思います。

1.2 URPの構成とカスタムパスの導入方針

URP(Universal Render Pipeline)はUnity標準の軽量でモダンなレンダリングパイプラインです。今回はこちらのポストプロセスの一種である、「Bloom」をカスタムしていこうと思います。

通常のBloomでは輝度が一定を超えた部分を取り出して、加算合成して発光処理させるのですが、演出上もう少し自由度が欲しくなるときがあります。たとえば特定のオブジェクトだけ光らせたいとか、発光の色味を調整したいといったケースです。

その要件のためには、

  1. 新しくBloom用のパスを作成し、シェーダーからそのBloom用テクスチャにレンダリングするテクスチャを作成する。
  2. Bloom用のシェーダーを書いて、既存のシェーダーに追加する。
  3. 新しいPostProcessを作りBloomを実装

image.png

この流れでレンダリングパスをカスタムしていこうと思います。

1.3 環境構築

検証した環境は、Unity6000.0 + Windows 11です。

Unity6では、URPでRenderGraphが使えるので、それに対応したものになります。

RenderGraphについて

レンダリングパイプラインで使うリソースを効率的に管理してくれる仕組みです。

image.png

リソースの生成と破棄のタイミングを自動的に最適化してくれるため、メモリの効率的な使用が可能になります。また、依存関係の管理も容易になり、レンダリングパスの順序も明確に把握できます。さらに、RenderGraphはデバッグツールも充実しているため、パフォーマンスのボトルネックを特定しやすいという利点があります。

環境構築に戻ります。

既存のURP環境に入れていく想定ですので、まずは一般的なURP環境としてURPのSampleSceneTemplateをお借りいたしました。既存のBloomと比較できるように二つのランタンを並べます。

image.png

2. カスタムパスの実装

2.1 実装方針

今回のCustomBloomPassの実装について、大まかな方針を説明していきます。まず、通常のカメラ描画とは別にBloom用の描画パスを用意し、そこで発光させたいオブジェクトだけを抽出します。その後、抽出したテクスチャをPostProcessに渡してBloom処理に合流させていく流れにしようと思います。

実装の詳細に入る前に、必要なプロセスを確認しておきましょう。

  1. RenderFeature の作成
  2. RenderPassの作成
    1. Bloom用の新しいテクスチャを作成する
    2. 特定のShaderTagIDを持つMaterialを持つオブジェクトのみフィルターする。
    3. 作成したテクスチャにレンダリングする。
  3. シェーダーの実装
    1. URPのLitシェーダーに新しいPassを追加する。
  4. 既存のポストプロセスのカスタマイズ
    1. 作成したPostProcessPassに作成したテクスチャを渡す
    2. Bloom処理のコードをカスタマイズする

これらの手順に沿って実装を進めていくことで、カスタマイズ可能なBloom効果を実現できます。それでは、具体的な実装の詳細を見ていきましょう。

2.2 RenderFeatureの作成

まずはC#スクリプトを用意し、RenderFeatureを継承したクラスを定義します。

RenderFeatureは、URPのレンダリングパイプラインに独自の描画機能を追加するためのコンポーネントです。カメラの描画処理に新しいパスを挿入したり、既存のパスをカスタマイズしたりすることができます。これにより、プロジェクト固有の描画要件に対応することが可能になります。

また、RenderFeatureクラス内にSerializableなパラメータを宣言しておくと、インスペクタで設定できるようになります。

CustomBloomRenderFeature.cs
using UnityEngine;
using UnityEngine.Rendering;
using UnityEngine.Rendering.RenderGraphModule;
using UnityEngine.Rendering.Universal;

public class CustomBloomRenderFeature : ScriptableRendererFeature
{
    CustomBloomPass customBloomPass;
    [SerializeField] private LayerMask layerMask;
    [SerializeField] private RenderQueueRange renderQueueRange = RenderQueueRange.opaque;

    // テクスチャの参照を保持するクラス
    public class CustomBloomTextureRef : ContextItem
    {
        // テクスチャの参照を保持する変数
        public TextureHandle texture = TextureHandle.nullHandle;

        // ContextItemが必要とするReset関数。次のフレームに引き継がれない変数をリセットする。
        public override void Reset()
        {
            texture = TextureHandle.nullHandle;
        }
    }

    public override void Create()
    {
        customBloomPass = new CustomBloomPass(renderQueueRange, layerMask);
    }

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

2.3 RenderPassの作成

RenderPassでは、そのPassで行われる具体的な処理を実装していきます。

処理の流れは以下のとおりです。

  1. テクスチャを作成し、描画設定を行う
    1. RenderGraphを使って新しいテクスチャを生成
    2. テクスチャの参照を他のパスで使えるように登録
  2. 描画対象のフィルタリング
    1. ShaderTagIDが一致するオブジェクトのみを抽出
    2. レイヤーマスクとレンダーキューの設定
  3. 実際の描画処理
    1. テクスチャの書き込み、読み込みの設定
    2. フィルタリングされたオブジェクトの描画

以下に実際に書いたコードを示します。

CustomBloomPass.cs
using UnityEngine;
using UnityEngine.Rendering;
using UnityEngine.Rendering.RenderGraphModule;
using UnityEngine.Rendering.Universal;
using static CustomBloomRenderFeature;

public class CustomBloomPass : ScriptableRenderPass
{
    private const string ProfilerTag = "Custom Bloom Pass";
    // ShaderのTagsでLightModeがこれになっているシェーダのみをレンダリング対象とする
    private ShaderTagId k_shaderTagId = new ShaderTagId("CustomBloom");
    private FilteringSettings m_FilteringSettings;

    private class BloomPassData
    {
        internal RendererListHandle rendererListHandle;
        internal TextureHandle destination;
        internal UniversalCameraData cameraData;
    }

    // 新しいRenderPassの作成
    public CustomBloomPass(RenderQueueRange renderQueueRange, LayerMask layerMask)
    {
        // パスを実行するタイミングの指定
        renderPassEvent = RenderPassEvent.BeforeRenderingPostProcessing;
        // レンダリング対象の設定
        m_FilteringSettings = new FilteringSettings(renderQueueRange, layerMask);
    }

    public override void RecordRenderGraph(RenderGraph renderGraph, ContextContainer frameData)
    {

        // カメラの情報を取得
        UniversalCameraData cameraData = frameData.Get<UniversalCameraData>();
        // リソースの情報を取得
        UniversalResourceData resourceData = frameData.Get<UniversalResourceData>();
        // レンダリング関連の情報を取得
        UniversalRenderingData renderingData = frameData.Get<UniversalRenderingData>();
        // ライティング関連の情報を取得
        UniversalLightData lightData = frameData.Get<UniversalLightData>();

        var textureDesc = renderGraph.GetTextureDesc(resourceData.activeColorTexture);
        textureDesc.name = "CustomBloomTexture";
        TextureHandle customBloomTextureHandle = renderGraph.CreateTexture(textureDesc);

        // テクスチャの参照をFrameDataに登録する。
        // これによって、他のパスでこのテクスチャを参照できるようになる。
        var texRefExist = frameData.Contains<CustomBloomTextureRef>();
        var texRef = frameData.GetOrCreate<CustomBloomTextureRef>();

        if (!texRefExist)
        {
            texRef.texture = resourceData.activeColorTexture;
        }

        texRef.texture = customBloomTextureHandle;

        // ShaderTagIDがBloomPassのRenderPassを作成
        using (var builder = renderGraph.AddRasterRenderPass<BloomPassData>(
            "Cusom Bloom Pass",
            out var passData,
            new ProfilingSampler(ProfilerTag))
        )
        {
            // レンダリングの設定
            builder.UseAllGlobalTextures(true);
            passData.cameraData = cameraData;
            passData.destination = customBloomTextureHandle;

            // レンダリングリストの作成(レンダリング対象のRendererを取得)
            SortingCriteria sortFlags = cameraData.defaultOpaqueSortFlags;
            var drawSettings = RenderingUtils.CreateDrawingSettings(k_shaderTagId, renderingData, cameraData, lightData, sortFlags);
            var param = new RendererListParams(renderingData.cullResults, drawSettings, m_FilteringSettings);
            passData.rendererListHandle = renderGraph.CreateRendererList(param);
            builder.UseRendererList(passData.rendererListHandle);

            // 最も重要な描画テクスチャの設定
            builder.SetRenderAttachment(customBloomTextureHandle, 0, AccessFlags.Write);
            // 描画順修正のための深度テクスチャに書き込み
            builder.SetRenderAttachmentDepth(resourceData.activeDepthTexture, AccessFlags.Write);
            builder.AllowPassCulling(false);
            builder.AllowGlobalStateModification(true);

            // パスの実行
            builder.SetRenderFunc((BloomPassData data, RasterGraphContext context) =>
                {
                    using (new ProfilingScope(context.cmd, new ProfilingSampler(ProfilerTag)))
                    {
                        ExecutePass(data, context);
                    }
                }
            );
        }
    }

    static void ExecutePass(BloomPassData data, RasterGraphContext context)
    {
        context.cmd.ClearRenderTarget(RTClearFlags.Color, Color.black, 1, 0);
        context.cmd.DrawRendererList(data.rendererListHandle);
    }
}


2.4 シェーダーの実装

シェーダーを実装していきます。シェーダー内にBloomPassで使う描画パスを定義し、それをSubShaderとして追加します。この実装により、オブジェクトごとに異なる発光強度や色味を設定できるようになります。

ここでは、URPのデフォルトのシェーダーである、LitシェーダーにCustomBloomというパスのシェーダー実装を追加していきます。

ここでは例として、Emissiveテクスチャのみを描画するシェーダーを書いていきます。

以下に実装例を示します。

CustomLit.shader
//通常のURP/litのシェーダーに新しいパスを追加する。

SubShader
{
	
	// ~~
	// ----- 通常のURP/litのシェーダー -----
	// ~~
	
	// Bloom用の独自のパスシェーダーを追加
	// 中身はほとんどURP/Unlitシェーダーと同じですが、
	// 色の出力はEmissive用のテクスチャと色のみを使用しています。
	Pass
  {
      Name "CustomBloom"
      Tags { "LightMode" = "CustomBloom" }

      // Render State Commands
      AlphaToMask[_AlphaToMask]

      HLSLPROGRAM
      #pragma target 2.0
      #pragma vertex UnlitPassVertex
      #pragma fragment UnlitPassFragment
      #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"

      // -------------------------------------
      // Material Keywords
      #pragma shader_feature_local_fragment _SURFACE_TYPE_TRANSPARENT
      #pragma shader_feature_local_fragment _ALPHATEST_ON
      #pragma shader_feature_local_fragment _ALPHAMODULATE_ON
  
      #include "Packages/com.unity.render-pipelines.universal/Shaders/UnlitInput.hlsl"
      #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Unlit.hlsl"
      #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Lighting.hlsl"

      half4 _EmissionColor;

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

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


      Varyings UnlitPassVertex(Attributes input)
      {
          Varyings output = (Varyings)0;


          VertexPositionInputs vertexInput = GetVertexPositionInputs(input.positionOS.xyz);

          output.positionCS = vertexInput.positionCS;
          output.uv = TRANSFORM_TEX(input.uv, _BaseMap);

          return output;
      }

      void UnlitPassFragment( Varyings input, out half4 outColor : SV_Target0)
      {

          half2 uv = input.uv;
          half4 texColor = SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, uv);
          float4 emissionColor = SAMPLE_TEXTURE2D(_EmissionMap, sampler_BaseMap, uv);

          half3 color = emissionColor.rgb * _EmissionColor.rgb;
          half alpha = texColor.a * _BaseColor.a;

          alpha = AlphaDiscard(alpha, _Cutoff);
          color = AlphaModulate(color, alpha);

          InputData inputData;

          half4 finalColor = UniversalFragmentUnlit(inputData, color, alpha);

          outColor = finalColor;
      }


      ENDHLSL

  }
}

※実際に使われているUnlitシェーダーから機能を大きく省いて、見やすいコードに整理した状態になります。

2.5 カスタムパスの確認

これでカスタムBloomパスができました。Frame Debuggerで確認してみましょう。

カスタムしたシェーダーから出力した結果のみが描画されていると思います。

image.png

3. 既存のポストプロセスのカスタマイズ

今回の例では、既存のポストプロセスのBloomと比較するために、新しくPostProcessのパスを作って行こうと思います。

流れは以下になります。

  1. CustomPostProcessPassの実装
    1. CustomBloomPassで作成した、テクスチャ参照を受け取る
    2. そのテクスチャにBlurをかけて加算合成するシェーダーを用意
    3. 描画(Blit)
  2. CustomPostProcessRenderFeatureでPassを登録
    1. Bloom用のパラメーターを登録

3.1 CustomPostProcessPassの作成

CustomBloomPassで作成したテクスチャを受け取る

カスタムのBloomPassで生成したテクスチャをCustomPostProcessPassで利用するには、frameDataを介してテクスチャの受け渡しを行う必要があります。上記のコードでは、CustomBloomTextureRefという型を使ってテクスチャの参照を管理し、CustomPostProcessPass内でそのテクスチャを読み取って処理を行っています。

カスタムポストプロセスの実装の流れはさきほどのRenderPassの実装と同じです。

他のパスで作成したテクスチャを受け取って、シェーダーにわたす流れを下記に端折って示します。

CustomPostProcess.cs
// ----- RecordRenderGraph -----

// frameDataにCustomBloomTextureRefが含まれていない場合は処理をスキップ
if (!frameData.Contains<CustomBloomTextureRef>()) return;

// テクスチャの参照をframeDataの中から取得
var bloomPassData = frameData.Get<CustomBloomTextureRef>();

// ----- AddRasterRenderPass -----

// パスデータに格納
passData.customBloomPassTexture = bloomPassData.texture;
// マテリアルはRenderFeatureで参照を指定
passData.material = material;

//読み取り権限を付与
builder.UseTexture(passData.customBloomPassTexture, AccessFlags.Read);

// ----- SetRenderFunc -----

// マテリアルのパラメーターに登録
data.material.SetTexture("_BloomTex", data.customBloomPassTexture); 

//Blitを実行
Blitter.BlitTexture(context.cmd, data.sourceTexture, new Vector4(1f, 1f, 0, 0), data.material, 0);       

3.2 そのテクスチャにBlurをかけて加算合成するシェーダーを用意

このシェーダーでは、CustomBloomPassで生成したテクスチャに対してガウシアンブラーを適用し、元のシーンに加算合成を行います。ブラーの半径(_BlurRadius)や強度(_Intensity)をパラメーターとして外部から制御できるため、発行表現の調整をすることが可能です。

URPのBloomでは、MipMapにBloomをかけるて合成することによって、より効率よく品質の良い結果を出していますが、こちらのサンプルではコードのシンプルさを優先して、ガウシアンブラーで実装しました。

結果の違いも比較すると楽しいかと思います。

GausianBloom.shader
Shader "GaussianBloom"
{
   SubShader
   {
       Tags { "RenderType"="Opaque" "RenderPipeline" = "UniversalPipeline"}
       ZWrite Off Cull Off
       Pass
       {
           Name "GaussianBloomPass"

           HLSLPROGRAM
           #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
           #include "Packages/com.unity.render-pipelines.core/Runtime/Utilities/Blit.hlsl"

           #pragma vertex Vert
           #pragma fragment Frag

           TEXTURE2D_X(_CustomTex);
           float4 _CustomTex_TexelSize;
           float _BlurRadius;
           float _Intensity;

           // ガウス関数
           float Gaussian(float x, float sigma)
           {
               return exp(-(x * x) / (2.0 * sigma * sigma)) / (sqrt(2.0 * 3.1415926535) * sigma);
           }

           // ガウスブラーを適用する関数
           half4 GaussianBlur(float2 uv, float2 texelSize, float sigma)
           {
               half4 color = 0;
               float weightSum = 0;

               for (int x = -_BlurRadius; x <= _BlurRadius; x++)
               {
                   for (int y = -_BlurRadius; y <= _BlurRadius; y++)
                   {
                       float2 offset = float2(x, y) * texelSize;
                       float weight = Gaussian(length(offset), sigma);
                       color += SAMPLE_TEXTURE2D_X_LOD(_CustomTex, sampler_LinearRepeat, uv + offset, _BlitMipLevel) * weight;
                       weightSum += weight;
                   }
               }

               return color / weightSum;
           }

           // Out frag function takes as input a struct that contains the screen space coordinate we are going to use to sample our texture. It also writes to SV_Target0, this has to match the index set in the UseTextureFragment(sourceTexture, 0, …) we defined in our render pass script.   
           float4 Frag(Varyings input) : SV_Target0
           {
               // this is needed so we account XR platform differences in how they handle texture arrays
               UNITY_SETUP_STEREO_EYE_INDEX_POST_VERTEX(input);

               // sample the texture using the SAMPLE_TEXTURE2D_X_LOD
               float2 uv = input.texcoord.xy;
               half4 color = SAMPLE_TEXTURE2D_X_LOD(_BlitTexture, sampler_LinearRepeat, uv, _BlitMipLevel);
               half4 color2 = GaussianBlur(uv, _CustomTex_TexelSize.xy, _BlurRadius);
               // シンプルな加算合成
               color = color + color2 * _Intensity;
               return color;
           }

           ENDHLSL
       }
   }
}

3.3 ビジュアル確認

これで一通りの実装が完了しました。

デフォルトのBloomでは飽和した光のみがBloom処理の対象になるため、よく白飛びしたような表現になります。

今回カスタムしたBloomでは、指定した色をBloom処理の対象にするため、白飛びさせずに色を拡散することが可能になります。

image.png

4. まとめ

今回は、URPのRenderGraphを使用したカスタムBloomPassの実装方法について解説しました。

初めてのRenderPipelineのカスタムでしたので、至らない点もあるかと思いますが、概ね結果には満足しています。 ScriptableRenderPipelineの概要と具体的な実装を把握する良い機会になったかとおもいます。

今回の実装は、複数のRenderPassで実装しましたが、UnsafePassで一つにまとめて見ることも可能かと思います。

参考記事

https://docs.unity3d.com/ja/Packages/com.unity.render-pipelines.core@10.7/manual/render-graph-writing-a-render-pipeline.html

https://blog.sge-coretech.com/entry/2024/06/04/171757

GitHubで編集を提案
2

Discussion

ログインするとコメントできます