はじめに
最近のUnityの傾向として、かつてからある伝統的なBuilt-in Render Pipelineから、Universal Render Pipeline(URP)に移行せよ...という圧力を感じます。Built-in Render Pipelineを選択するのもアリだよとも聞くので、悩みどころとなります。
一方自分は実務ではここ1年程Unity 2019/Built-in Redner Pipelineを開発で使ってきました。
Unity2018の頃はLWRPと呼ばれていましたが採用せず(正直に言えばこれは正解だったと思う)、後にURPと改名されたことを知りまだまだ安定してないのでは等の不安感が強く、実務での利用を控えてきました。なによりただでさえ遅れがちなゲーム開発の進捗が遅れることを恐れたためです。
しかしながらShader Graph使いたい、というデザイナーさんからの要望も高まっており(現に使っていてデザイナーさん凄いなと思うのですが)、これがURPでないと利用できないためShader Graphを使いたいがために「各社頑張って対応してそうだな」などと想像しています。
また、先日Unity 2020がLTSとなったことを受け「このままではゲームプログラマとして不味い」という強迫観念を感じ、いよいよ自分もBuilt-in Render Pipelineを捨ててURPに乗り換える気になってきた、というのが最近の状況です。(つまりURP完全初心者!)
調査がてらポストエフェクトないしブラー処理から入門してみることにしました。ブラー処理は手軽に実装できかつ比較的よくある要望の1つであるためです。なによりポストエフェクトにはロマンがある。
なお、ここでいうブラー処理とは移動ベクトルを使うモーションブラーのことではなく、画像処理でいうところのガウシアンフィルタのようなぼかし処理です。
実行環境
Unity: 2020.3.0f1
Core RP Library: 10.3.2
Universal RP: 10.3.2
手始めに先人の知恵を借りる
まずどこから手をつけたら良いか分からなかったので、ググったところ下記の2つが大変参考になりました。
Unity HubからUniversal Render Pipelineテンプレートから新規プロジェクトを作成して上記の2つのRenderFeatureを組み込んでみたのが下記。
Unity URPを探り始めた。とりあえずポストプロセスとしてグレースケール変換&ズームブラーを試す。楽しい
— 山下 貴史 (@kishi_yama) March 7, 2021
参考:
グレースケール変換 👉https://t.co/zsJ3mNS2iX
ズームブラー
👉 https://t.co/T2F8RIqJ67 pic.twitter.com/BpWScXwtY1
ちょっとバトルシーンに入る前のエフェクトみたいになって楽しいです。やっぱりポストエフェクトにはロマンがある!
これはこれで素晴らしいのですが当初やりたかったのは、ぼかし処理なのだ、ということを思い出す。
UnityのPost Processing Stackについて
Unity 2018を触っていた頃Post Processing Stack v2というものがあることを知り、これを使えば比較的簡単にポストエフェクトを実装できる凄いやつ、程度の認識をしていました(元々はPostFx v2というものだったそうです)。
さらに遡るとUnity 5.xの頃のUnityマニュアルを見ると、この頃はそもそもデフォルトでBlurがあったようです。当時はRenderTextureベースでスクリーンに対するImage Effectという位置付けで実装されていたようです。
一方Post Processing Stack v2のリファレンスのEffectsを見るとでは、カメラの動きによるモーションブラーは実装されているものの、ガウシアンフィルタのようなものはありません。
Post Processing Stack v2でこれを自前実装することなしに実現したい場合はUnityToolsetを導入する必要があるとのことでした。
なるほど、一体何故かつて提供していたにも関わらずデフォルトで実装されていないのか。
それは自分で実装してくださいよ、というメッセージなのだろうか。やりましょう。
さらにURPの進展からかPost Processing Stack v2はレガシーとのこと。僅か2年程でレガシーというのが何ともアレなのですが、Post Processing Stack v3に引き継がれる流れのようです。消えなくて良かった。
では、Post Processing Stack v3ではどうなのかというと、Unity Forumでも同様の質問があり、特に将来的に実装予定でもなさそうです。代わりにURPのScriptableRenderFeatureで実装したデモがこのフォーラムで紹介されており、参考になりそうです。
やりたかったのはコレですよコレ!勉強になります!(新しいオモチャを見つけた子供のような心で)
デモの内容を見る
デモを見ると分かるのですが、このデモではblur
マテリアルにCustom/RenderFeature/KawaseBlur
シェーダ、glass
マテリアルにShaderGraphs/BathroomGlass
シェーダが付けれており、グラス表現を適用したPlaneに対して川瀬ブラーを施す、というものです。
初見どこを弄ればブラー効果が確認できるのか分からなかったのですが、ForwardRenderer
でプロパティが露出していました。
手始めに先人の知恵を借りる
では共にVolumeComponent
を継承したクラスでプロパティが実装されておりVolume
コンポーネントのインスペクターにプロパティが露出していたため多少混乱しました。
一方こちらのデモではVolume framework
(URP 10.1.0以前ではVolume system
と書かれていた)は関係なく、KawaseBlurSettings
がScriptableRendererFeature
内で定義されており、これが設定項目としてFowardRenderer
に露出しているようです。
各設定項目を見てみましょう。
Render Pass Event
Render Passを差し込むタイミングの指定です。
どのタイミングが良いかはRenderPassEvent.BeforeRenderingPostProcessing
もしくはRenderPassEvent.AfterRenderingTransparents
あたりかな、と思いました。
Blur Material
KawaseBlurに紐づくマテリアルを指定します。
Blur Passes
ブラーの強さです。値を大きくすればするほど強いブラーとなりますが、負荷が気になるところです。
Downsample
負荷低減のためダウンサンプリングする際のテクスチャ縮小具合です。
下記の計算でテクスチャを縮小しているようです。つまり2を指定すればテクスチャサイズは1/4となりその分負荷が軽くなりそうです。
var width = cameraTextureDescriptor.width / downsample;
var height = cameraTextureDescriptor.height / downsample;
ある程度負荷を抑える制御ができる点が玄人的で素晴らしいです。
Copy To Framebuffer
このデモはBathroomGlass.shadergraph
で参照しているテクスチャに対してブラーをかけるだけかと思いきやこのオプションを有効にするとPlaneだけでなくスクリーン全体にブラーが効きました(これが本当にやりたかったこと)。
Target Name
_blurTexture
という文字列が指定されています。
これはBathroomGlass.shadergraph
で参照されるプロパティのようです。
この辺りはまだShaderGraphを調査していないため雰囲気で言っています。
BathroomGlass.shadergraph
のインスペクターで「Edit」を押すとShaderGraphのエディタが開き、中身を確認できました。
なおKawaseShader.shader
については何故かmulti_compile_fog
というpragmaがあり本当にコレが必要なのかどうなのかは良く分かりませんでした(フォーラムでも質問が上がっているが特に返答は得られていない様子)。
川瀬ブラーのみ取り出してVolumeComponentで扱えるようにする
上記のデモがあれば十分なのですが、このままだとVolume framework
に適応していません。
Volume framework
に則るとweight
で「ポストエフェクトがどの程度影響するか」を調整できるため、便利そうです。
また、volume.profile.TryGet<MyVolumeComponent>(out var volumeComponent)
のように自作したVolumeComponent
にアクセスしてプロパティをプログラムから制御できるため、都合が良さそうです。
そこで手始めに先人の知恵を借りる
で紹介されているような方法で書き直してみます。
今回スクリーン全体に適用されたら十分だったため必要なさそうな箇所は適当に調整して簡略化しました。
using UnityEngine.Rendering;
using UnityEngine.Rendering.Universal;
public class KawaseBlur : VolumeComponent, IPostProcessComponent
{
// RangeではなくClampedIntParameterを使うことでインスペクター上でスライダーとなることに注意。
public ClampedIntParameter passes = new ClampedIntParameter(2, 2, 20);
public IntParameter downsample = new ClampedIntParameter(1, 1, 10);
public bool IsActive() => passes.value >= 2;
public bool IsTileCompatible() => false;
}
using UnityEngine;
using UnityEngine.Rendering;
using UnityEngine.Rendering.Universal;
public class KawaseBlurRenderFeature : ScriptableRendererFeature
{
class CustomRenderPass : ScriptableRenderPass
{
static readonly string RenderTag = "Kawase Blur Effect";
KawaseBlur kawaseBlur = default;
Material material = default;
RenderTargetIdentifier source = default;
RenderTargetIdentifier tmpRT1 = default;
RenderTargetIdentifier tmpRT2 = default;
int tmpId1 = default;
int tmpId2 = default;
public CustomRenderPass(RenderPassEvent evt)
{
renderPassEvent = evt;
var shader = Shader.Find("Custom/RenderFeature/KawaseBlur");
if (shader == default)
{
return;
}
material = CoreUtils.CreateEngineMaterial(shader);
}
public void Setup(RenderTargetIdentifier source)
{
this.source = source;
}
public override void Configure(CommandBuffer cmd, RenderTextureDescriptor cameraTextureDescriptor)
{
if (kawaseBlur == default)
{
var stack = VolumeManager.instance.stack;
kawaseBlur = stack.GetComponent<KawaseBlur>();
}
if (!IsValid()) return;
var downsample = kawaseBlur.downsample.value;
var width = cameraTextureDescriptor.width / downsample;
var height = cameraTextureDescriptor.height / downsample;
tmpId1 = Shader.PropertyToID("tmpBlurRT1");
tmpId2 = Shader.PropertyToID("tmpBlurRT2");
cmd.GetTemporaryRT(tmpId1, width, height, 0, FilterMode.Bilinear, RenderTextureFormat.ARGB32);
cmd.GetTemporaryRT(tmpId2, width, height, 0, FilterMode.Bilinear, RenderTextureFormat.ARGB32);
tmpRT1 = new RenderTargetIdentifier(tmpId1);
tmpRT2 = new RenderTargetIdentifier(tmpId2);
ConfigureTarget(tmpRT1);
ConfigureTarget(tmpRT2);
}
public override void Execute(ScriptableRenderContext context, ref RenderingData renderingData)
{
if (!renderingData.cameraData.postProcessEnabled) return;
if (!IsValid()) return;
var passes = kawaseBlur.passes.value;
CommandBuffer cmd = CommandBufferPool.Get(RenderTag);
RenderTextureDescriptor opaqueDesc = renderingData.cameraData.cameraTargetDescriptor;
opaqueDesc.depthBufferBits = 0;
// first pass
cmd.SetGlobalFloat("_offset", 1.5f);
cmd.Blit(source, tmpRT1, material);
for (var i = 1; i < passes - 1; i++) {
cmd.SetGlobalFloat("_offset", 0.5f + i);
cmd.Blit(tmpRT1, tmpRT2, material);
// pingpong
var rttmp = tmpRT1;
tmpRT1 = tmpRT2;
tmpRT2 = rttmp;
}
// final pass
cmd.SetGlobalFloat("_offset", 0.5f + passes - 1f);
cmd.Blit(tmpRT1, source, material);
context.ExecuteCommandBuffer(cmd);
cmd.Clear();
CommandBufferPool.Release(cmd);
}
bool IsValid()
{
if (material == default)
{
Debug.LogError("material is not found.");
return false;
}
if (kawaseBlur == default)
{
Debug.LogError("kawaseBlur is not found.");
return false;
}
return kawaseBlur.IsActive();
}
}
CustomRenderPass scriptablePass = default;
public override void Create()
{
scriptablePass = new CustomRenderPass(RenderPassEvent.AfterRenderingTransparents);
}
public override void AddRenderPasses(ScriptableRenderer renderer, ref RenderingData renderingData)
{
scriptablePass.Setup(renderer.cameraColorTarget);
renderer.EnqueuePass(scriptablePass);
}
}
Shader "Custom/RenderFeature/KawaseBlur"
{
Properties
{
_MainTex ("Texture", 2D) = "white" {}
}
SubShader
{
Tags { "RenderPipeline" = "UniversalPipeline" }
Cull Off ZWrite Off ZTest Always
Pass
{
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "UnityCG.cginc"
struct appdata
{
float4 vertex : POSITION;
float2 uv : TEXCOORD0;
};
struct v2f
{
float2 uv : TEXCOORD0;
float4 vertex : SV_POSITION;
};
sampler2D _MainTex;
float4 _MainTex_TexelSize;
float4 _MainTex_ST;
float _offset;
v2f vert (appdata v)
{
v2f o;
o.vertex = UnityObjectToClipPos(v.vertex);
o.uv = TRANSFORM_TEX(v.uv, _MainTex);
return o;
}
fixed4 frag (v2f input) : SV_Target
{
float2 res = _MainTex_TexelSize.xy;
float i = _offset;
fixed4 col = float4(0, 0, 0, 1);
col.rgb = tex2D(_MainTex, input.uv ).rgb;
col.rgb += tex2D(_MainTex, input.uv + float2( i, i) * res).rgb;
col.rgb += tex2D(_MainTex, input.uv + float2( i, -i) * res).rgb;
col.rgb += tex2D(_MainTex, input.uv + float2(-i, i) * res).rgb;
col.rgb += tex2D(_MainTex, input.uv + float2(-i, -i) * res).rgb;
col.rgb /= 5.0f;
return col;
}
ENDCG
}
}
}
Editor上では下記のようなことまでできました。(解像度的に見辛いかもしれませんが...)
ふぅ...できた。 pic.twitter.com/3888qsvsJe
— 山下 貴史 (@kishi_yama) March 14, 2021
実機検証
普段はiOS/Androidで開発していますが、AndroidではVulkan/OpenGL ESの差でハマりがちなので、警戒が必要です。
手元のiPhone 12, Google Pixel XL(Vulkan)では無事動作しましたが、Open GL ES 3.0にフォールバックされた場合は手元に検証端末がなく、確認していません。
まとめ
簡単なポストエフェクト実装からURPに入門してみました。
Scriptable Rendering Pipelineまわりは開発が活発で次から次へと状況が変わっており、ただでさえ敷居の高いGraphicsに拍車をかけて追いかけるのが難しいです。
また作業中何かの拍子にGameViewに何も映らなくなりUnity再起動で直ったり、VolumeComponent
で公開するClampedIntParameter
のmin,maxの値を変更して再コンパイルしたときに正常に反映されず一度VolumeComponent
を外して付け直すと直る、見慣れない警告が出たりといったような事が発生して少し驚きます。
とはいえ最近はモバイルゲームといえど、ハイクオリティなタイトルも珍しくなくなってきているため、今後もURPへの期待は業界内で高まっていくのが自然な流れかなと思っています。
ある程度手応えを得ることができたので、これを皮切りに今後もリファレンスやサンプルを読み漁りつつ追いかけてみようと思います。