概要
最近、Apex Legendsにハマって毎日のように時間が吸われていってます。(まずい)
さて、今回はこのApex LegendsのUIで使われているような「背景がボケているUI」を作る方法を書いていきたいと思います。
↓こんな感じで、背景が透過+ボケている「すりガラス」風のUIですね。
↓実際に実装した動画はこんな感じ
uGUIの背景をぼかす pic.twitter.com/zqcoxohaNc
— edom18@AR / MESON (@edo_m18) 2019年4月2日
背景がぼけているだけでなんか途端にオシャレ感でますよねw
今回のサンプルはGithubにアップしてあります。
フロー
今回の実装のフローは大まかに以下のような感じです。
- CommandBufferを使って画面をキャプチャする
- キャプチャした画像をブラー用のシェーダで加工する(※1)
- ブラーをかけた画像をuGUI用シェーダで合成する(※2)
という流れになります。
※1 ... ブラー画像を生成する方法については以前の記事で書いたのでそちらを参照ください。
CommandBufferを使って画面をキャプチャしブラーをかける
まず最初に行うのはCommandBuffer
による画面キャプチャです。
CommandBuffer自体については以下のドキュメントか凹みさんの記事にさらに詳しく書いてあるのでそちらも参考にしてみてください。
CommandBufferとは
CommandBuffer
とは、ドキュメントから引用すると以下のように記述されています。
これは、いわゆる “command buffers” で、Unity の Unity のレンダリングパイプライン を拡張することができます。 コマンドバッファがレンダリングコマンド(“set render target, draw mesh, …”)のリストを保持し、 カメラがレンダリング中にさまざまなポイントで実行するように設定することができます。
GPUは「コマンドバッファ」と呼ばれる、CPUからGPUに対して命令を送るためのバッファの仕組みがあります。いわゆると書いているのは、まさにこのことと同じことを指しているのだと思います。
なお、このあたりのGPUのコマンドバッファに関しては以下の記事が分かりやすいでしょう。興味がある人は読んでみてください。
コマンドバッファとは、ざっくりと言うとCPUからGPUへ「こういう処理をしてから描画をこういうデータでやってね」という「コマンドを積み重ねたバッファ」、ということができます。
余談
余談ですが、なぜコマンドバッファという仕組みがあるのでしょうか。
CPUとGPUではその処理速度や実行するタスクなどが違うため協調して動くことができません。
そのため、CPU側で描画に必要なデータを準備し必要なタスクの順番などを定義したあと、それらをGPUに「依頼」する必要があります。
実際の仕事に置き換えて考えてみると、専門的な仕事に関してはその専門家に依頼することが自然な流れでしょう。
そして依頼したあとはその仕事が終わるまで待つことはせず、自分の仕事に戻るのが普通です。
これと似たようなことがCPUとGPUとの間で行われているわけです。
つまり、コマンドバッファにはGPUにやってほしいことを列挙し、それをGPUに渡して「レンダリング」という専門タスクを依頼するわけです。
そうして仕事の例と同じように、GPUにタスクを依頼したあとはCPUは自分のタスクに戻ります。
これが、「コマンドバッファ」と呼ばれるタスクのバッファを橋渡しとして利用する理由です。
このあたりのハード面からの理屈やGPUの仕組みについては以下の書籍がとても参考になりました。
興味がある方は読んでみると、GPU周りについて詳しくなるのでオススメです。
閑話休題。
Unityの場合はCommandBuffer
はカメラに対して設定するようになっています。
まずはコードを見てみましょう。
CommandBufferをセットアップしているC#コード
using System.Collections; using System.Collections.Generic; using UnityEngine; using UnityEngine.Rendering; namespace Example.uGUI { /// <summary> /// Screen capture using CommandBuffer. /// </summary> public class SceneBlurEffect : MonoBehaviour { [SerializeField] private Shader _shader; [SerializeField, Range(1f, 100f)] private float _offset = 1f; [SerializeField, Range(10f, 1000f)] private float _blur = 100f; [SerializeField, Range(0f, 1f)] private float _intencity = 0; [SerializeField] private CameraEvent _cameraEvent = CameraEvent.AfterImageEffects; private Material _material; private Dictionary<Camera, CommandBuffer> _cameras = new Dictionary<Camera, CommandBuffer>(); private float[] _weights = new float[10]; private bool _enabledBlur = false; private bool _isInitialized = false; public float Intencity { get { return _intencity; } set { _intencity = value; } } private int _copyTexID = 0; private int _blurredID1 = 0; private int _blurredID2 = 0; private int _weightsID = 0; private int _intencityID = 0; private int _offsetsID = 0; private int _grabBlurTextureID = 0; private void Awake() { // OnWillRenderObjectが呼ばれるように、MeshRendererとMeshFilterを追加する MeshFilter filter = gameObject.AddComponent<MeshFilter>(); filter.hideFlags = HideFlags.DontSave; MeshRenderer renderer = gameObject.AddComponent<MeshRenderer>(); renderer.hideFlags = HideFlags.DontSave; _copyTexID = Shader.PropertyToID("_ScreenCopyTexture"); _blurredID1 = Shader.PropertyToID("_Temp1"); _blurredID2 = Shader.PropertyToID("_Temp2"); _weightsID = Shader.PropertyToID("_Weights"); _intencityID = Shader.PropertyToID("_Intencity"); _offsetsID = Shader.PropertyToID("_Offsets"); _grabBlurTextureID = Shader.PropertyToID("_GrabBlurTexture"); Transform parent = Camera.main.transform; transform.SetParent(parent); transform.localPosition = Vector3.forward; UpdateWeights(); } private void Update() { foreach (var kv in _cameras) { kv.Value.Clear(); BuildCommandBuffer(kv.Value); } } private void OnEnable() { Cleanup(); } private void OnDisable() { Cleanup(); } public void OnWillRenderObject() { if (!gameObject.activeInHierarchy || !enabled) { Cleanup(); return; } if (_material == null) { _material = new Material(_shader); _material.hideFlags = HideFlags.HideAndDontSave; } Camera cam = Camera.current; if (cam == null) { return; } #if UNITY_EDITOR if (cam == UnityEditor.SceneView.lastActiveSceneView.camera) { return; } #endif if (_cameras.ContainsKey(cam)) { return; } // コマンドバッファ構築 CommandBuffer buf = new CommandBuffer(); buf.name = "Blur AR Screen"; _cameras[cam] = buf; BuildCommandBuffer(buf); cam.AddCommandBuffer(_cameraEvent, buf); } private void BuildCommandBuffer(CommandBuffer buf) { buf.GetTemporaryRT(_copyTexID, -1, -1, 0, FilterMode.Bilinear); buf.Blit(BuiltinRenderTextureType.CurrentActive, _copyTexID); // 半分の解像度で2枚のRender Textureを生成 buf.GetTemporaryRT(_blurredID1, -2, -2, 0, FilterMode.Bilinear); buf.GetTemporaryRT(_blurredID2, -2, -2, 0, FilterMode.Bilinear); // 半分にスケールダウンしてコピー buf.Blit(_copyTexID, _blurredID1); // コピー後はいらないので破棄 buf.ReleaseTemporaryRT(_copyTexID); float x = _offset / Screen.width; float y = _offset / Screen.height; buf.SetGlobalFloatArray(_weightsID, _weights); buf.SetGlobalFloat(_intencityID, Intencity); // 横方向のブラー buf.SetGlobalVector(_offsetsID, new Vector4(x, 0, 0, 0)); buf.Blit(_blurredID1, _blurredID2, _material); // 縦方向のブラー buf.SetGlobalVector(_offsetsID, new Vector4(0, y, 0, 0)); buf.Blit(_blurredID2, _blurredID1, _material); buf.SetGlobalTexture(_grabBlurTextureID, _blurredID1); } private void Cleanup() { foreach (var cam in _cameras) { if (cam.Key != null) { cam.Key.RemoveCommandBuffer(_cameraEvent, cam.Value); } } _cameras.Clear(); Object.DestroyImmediate(_material); } private void OnValidate() { if (!Application.isPlaying) { return; } UpdateWeights(); } public void EnableBlur(bool enabled) { _enabledBlur = enabled; } private void UpdateWeights() { float total = 0; float d = _blur * _blur * 0.001f; for (int i = 0; i < _weights.Length; i++) { float r = 1.0f + 2.0f * i; float w = Mathf.Exp(-0.5f * (r * r) / d); _weights[i] = w; if (i > 0) { w *= 2.0f; } total += w; } for (int i = 0; i < _weights.Length; i++) { _weights[i] /= total; } } } }
ちょっと長いので面食らってしまった方もいるかもしれませんが、大事な点は以下です。
CommandBuffer
を生成し、必要な処理(コマンド)をバッファに設定する- セットアップした
CommandBuffer
をカメラに設定する
という2点のみです。
画面のキャプチャとブラー処理
これを行っているところを抜粋すると以下の部分になります。
public void OnWillRenderObject() { // レンダリング対象オブジェクトが非表示の場合はコマンドバッファをクリアして終了 if (!gameObject.activeInHierarchy || !enabled) { Cleanup(); return; } // コマンドバッファのレンダリングで使用するマテリアルをシェーダファイルから生成する if (_material == null) { _material = new Material(_shader); _material.hideFlags = HideFlags.HideAndDontSave; } // 現在、このコンポーネントを持っているオブジェクトをレンダリングしようとしているカメラへの参照 // (メインカメラひとつなら通常は一回だけ呼ばれる) Camera cam = Camera.current; if (cam == null) { return; } // 対象カメラに対してすでにコマンドバッファが生成済みなら終了 if (_cameras.ContainsKey(cam)) { return; } // コマンドバッファ構築 CommandBuffer buf = new CommandBuffer(); // あとでデバッグするときに分かりやすいように名前をつけておく buf.name = "Blur AR Screen"; // 生成したコマンドバッファをキャッシュする _cameras[cam] = buf; // コマンドバッファに対して必要な設定を行う BuildCommandBuffer(buf); // 対象カメラに対してコマンドバッファを登録する cam.AddCommandBuffer(_cameraEvent, buf); } // 実際のコマンドバッファの構築処理 private void BuildCommandBuffer(CommandBuffer buf) { // テンポラリのスクリーンサイズと同じサイズのRenderTextureを取得する(-1の指定がスクリーンサイズと同じサイズを意味する) buf.GetTemporaryRT(_copyTexID, -1, -1, 0, FilterMode.Bilinear); // 現在のアクティブなRenderTextureから、取得したテンポラリのRenderTextureへ単純にコピーする buf.Blit(BuiltinRenderTextureType.CurrentActive, _copyTexID); // 半分の解像度で2枚のRender Textureを生成(-2が、スクリーンサイズの半分(1/2)を意味する) // ふたつ取得しているのは、縦方向と横方向の2回、ブラー処理を分けて行うため buf.GetTemporaryRT(_blurredID1, -2, -2, 0, FilterMode.Bilinear); buf.GetTemporaryRT(_blurredID2, -2, -2, 0, FilterMode.Bilinear); // スクリーンサイズと同サイズのRTから半分のサイズのRTにコピーを行うことで、自動的に半分のサイズにしてくれる buf.Blit(_copyTexID, _blurredID1); // コピー後はいらないので破棄 buf.ReleaseTemporaryRT(_copyTexID); // ブラーのためにテクセルをフェッチするオフセットを、スクリーンサイズで正規化する float x = _offset / Screen.width; float y = _offset / Screen.height; // ガウシアンブラー用の「重み」パラメータを設定する buf.SetGlobalFloatArray(_weightsID, _weights); // ブラーの全体的な強さのパラメータを設定する buf.SetGlobalFloat(_intencityID, Intencity); // 横方向のブラー buf.SetGlobalVector(_offsetsID, new Vector4(x, 0, 0, 0)); buf.Blit(_blurredID1, _blurredID2, _material); // 縦方向のブラー buf.SetGlobalVector(_offsetsID, new Vector4(0, y, 0, 0)); buf.Blit(_blurredID2, _blurredID1, _material); // ブラーをかけた最終結果のテクスチャをグローバルテクスチャとして設定する buf.SetGlobalTexture(_grabBlurTextureID, _blurredID1); }
抜粋したコードにはなにを行っているかをコメントしてあります。
処理の流れにの詳細についてはそちらをご覧ください。
大雑把に流れを説明すると、コマンドバッファを生成してブラー処理のためのコマンドを構築し、それをカメラに登録する、という流れになります。
コマンドバッファの構築についての大雑把な流れは、
- 現在レンダリング済みの内容をテンポラリなRenderTextureに等倍でコピーする
- (1)でコピーしたものをさらに半分のサイズにコピーする
- (2)の半分サイズのテクスチャに対し、横方向のブラーをかける
- (3)の横方向ブラーの画像に対し、さらに縦方向のブラーをかける
- (4)の最終結果を、グローバルなテクスチャとして登録する
という手順で最終的なブラーがかかった画像を生成しています。
実際に、Frame Debuggerを使うとこの過程を見ることができます。
BuildCommandBuffer
メソッドでこのあたりの処理を行っています。
このセットアップ部分を理解する上で重要な点が「この構築を行っている時点では実際に描画処理は行われていない」という点です。
前述のように、CPUからGPUへは「コマンドバッファ」と呼ばれるバッファに命令を積み込んで送る、と説明しました。
つまりここではその「命令群」をバッファに積み込んでいるだけなので、これが実際にGPUに届いて処理されるのは設定したイベントのタイミングとなります。
ちなみに「設定したイベントのタイミング」とは、カメラに設定するときに指定したイベントの種類のことです。
以下のところですね。
cam.AddCommandBuffer(_cameraEvent, buf);
_cameraEvent
は(今回のサンプルでは)デフォルトでCameraEvent.AfterImageEffects
が設定されています。
読んで分かる通り、イベントタイプはイメージエフェクトのあと、となります。
イベントの種類は、ドキュメントから画像を引用させていただくと以下のようなタイミングに処理を差し込むことができるようになっています。
緑の丸い点のところが差し込める位置ですね。
今回はこのうち、イメージエフェクトのあと、というタイミングで処理を行っているわけです。
ブラー処理用シェーダ
ちなみに、コマンドバッファで使用しているブラーのためのシェーダも載せておきます。
こちらのシェーダについては以前の記事(Unityでガウシアンブラーを実装する)を参照ください。
Shader "UI/BlurEffect" { Properties { _MainTex("Texture", 2D) = "white" {} } SubShader { Tags { "RenderType"="Opaque" } LOD 100 Pass { ZTest Off ZWrite Off Cull Back CGPROGRAM #pragma fragmentoption ARB_precision_hint_fastest #pragma vertex vert #pragma fragment frag #include "UnityCG.cginc" struct appdata { float4 vertex : POSITION; float2 uv : TEXCOORD0; }; struct v2f { float4 vertex : SV_POSITION; half2 uv : TEXCOORD0; }; sampler2D _MainTex; half4 _Offsets; float _Intencity; static const int samplingCount = 10; half _Weights[samplingCount]; v2f vert (appdata v) { v2f o; o.vertex = UnityObjectToClipPos(v.vertex); o.uv = v.uv; return o; } fixed4 frag (v2f i) : SV_Target { half4 col = 0; [unroll] for (int j = samplingCount - 1; j > 0; j--) { col += tex2D(_MainTex, i.uv - (_Offsets.xy * j * _Intencity)) * _Weights[j]; } [unroll] for (int j = 0; j < samplingCount; j++) { col += tex2D(_MainTex, i.uv + (_Offsets.xy * j * _Intencity)) * _Weights[j]; } return col; } ENDCG } } }
uGUIをカスタムする
最後に、上記までで得られたブラー画像を利用してuGUIの背景を作る過程を書いていきます。
この「ブラーをかけた画像を利用する」ため、少しだけシェーダを書かないとなりません。
また通常のモデルとは異なり、uGUIのシェーダは専用の処理もあるため普通に生成したシェーダでそのまま書いてもうまく動きません。
このあたりはテラシュールブログさんの以下の記事を参考にさせていただきました。
要点だけ書いておくと、Unityの公式サイトからビルトインシェーダをDLしてきて、その中でUI用のシェーダをベースにカスタムしていく、という感じです。
カスタム後のシェーダを見てみましょう。
カスタム後のシェーダコード
Shader "UI/BlurScreen" { Properties { [PerRendererData] _MainTex ("Sprite Texture", 2D) = "white" {} _Color ("Tint", Color) = (1,1,1,1) _StencilComp ("Stencil Comparison", Float) = 8 _Stencil ("Stencil ID", Float) = 0 _StencilOp ("Stencil Operation", Float) = 0 _StencilWriteMask ("Stencil Write Mask", Float) = 255 _StencilReadMask ("Stencil Read Mask", Float) = 255 _ColorMask ("Color Mask", Float) = 15 [Toggle(UNITY_UI_ALPHACLIP)] _UseUIAlphaClip ("Use Alpha Clip", Float) = 0 } SubShader { Tags { "Queue"="Transparent" "IgnoreProjector"="True" "RenderType"="Transparent" "PreviewType"="Plane" "CanUseSpriteAtlas"="True" } Stencil { Ref [_Stencil] Comp [_StencilComp] Pass [_StencilOp] ReadMask [_StencilReadMask] WriteMask [_StencilWriteMask] } Cull Off Lighting Off ZWrite Off ZTest [unity_GUIZTestMode] Blend SrcAlpha OneMinusSrcAlpha ColorMask [_ColorMask] Pass { Name "Default" CGPROGRAM #pragma vertex vert #pragma fragment frag #pragma target 2.0 #include "UnityCG.cginc" #include "UnityUI.cginc" #pragma multi_compile __ UNITY_UI_CLIP_RECT #pragma multi_compile __ UNITY_UI_ALPHACLIP struct appdata_t { float4 vertex : POSITION; float4 color : COLOR; float2 texcoord : TEXCOORD0; UNITY_VERTEX_INPUT_INSTANCE_ID }; struct v2f { float4 vertex : SV_POSITION; fixed4 color : COLOR; float2 texcoord : TEXCOORD0; float4 worldPosition : TEXCOORD1; float4 pos : TEXCOORD2; UNITY_VERTEX_OUTPUT_STEREO }; sampler2D _MainTex; fixed4 _Color; fixed4 _TextureSampleAdd; float4 _ClipRect; float4 _MainTex_ST; sampler2D _GrabBlurTexture; v2f vert(appdata_t v) { v2f OUT; UNITY_SETUP_INSTANCE_ID(v); UNITY_INITIALIZE_VERTEX_OUTPUT_STEREO(OUT); OUT.worldPosition = v.vertex; OUT.vertex = UnityObjectToClipPos(OUT.worldPosition); OUT.texcoord = TRANSFORM_TEX(v.texcoord, _MainTex); OUT.pos = ComputeScreenPos(OUT.vertex); OUT.color = v.color * _Color; return OUT; } fixed4 frag(v2f IN) : SV_Target { float2 uv = IN.pos.xy / IN.pos.w; uv.y = 1.0 - uv.y; half4 color = (tex2D(_GrabBlurTexture, uv) + _TextureSampleAdd) * IN.color; #ifdef UNITY_UI_CLIP_RECT color.a *= UnityGet2DClipping(IN.worldPosition.xy, _ClipRect); #endif #ifdef UNITY_UI_ALPHACLIP clip (color.a - 0.001); #endif half4 mask = tex2D(_MainTex, IN.texcoord); color.a *= mask.a; return color; } ENDCG } } }
色々記載がありますが、修正した箇所はそれほど多くありません。(ほぼ、UnityのビルトインシェーダをDLしてきたコードそのままです)
編集した部分だけを抜粋すると以下の箇所になります。
struct v2f { /* ... 略 ... */ float4 pos : TEXCOORD2; // キャプチャした画像のテクセルをフェッチするための変数 /* ... 略 ... */ }; /* ... 略 ... */ // CommandBufferでキャプチャした画像が渡されてくる sampler2D _GrabBlurTexture; v2f vert(appdata_t v) { /* ... 略 ... */ // uGUIのメッシュの位置をスクリーン位置に変換する OUT.worldPosition = v.vertex; OUT.vertex = UnityObjectToClipPos(OUT.worldPosition); OUT.pos = ComputeScreenPos(OUT.vertex); /* ... 略 ... */ } fixed4 frag(v2f IN) : SV_Target { // デバイス正規化座標系とするため`w`で除算する // が、ComputeScreenPosの段階で正常な値が入っているっぽいが、 // シーンビューでちらつくのでこうしておく float2 uv = IN.pos.xy / IN.pos.w; // キャプチャ時に反転しているのでUVを反転してフェッチするようにする uv.y = 1.0 - uv.y; half4 color = (tex2D(_GrabBlurTexture, uv) + _TextureSampleAdd) * IN.color; /* ... 略 ... */ // uGUIのImageに設定されたテクスチャをマスク画像として利用する // 今回の例ではアルファ値でマスクしているが、白黒画像やその他の画像で独自にマスク位置を変更したい場合はここをいじる half4 mask = tex2D(_MainTex, IN.texcoord); color.a *= mask.a; return color; }
追記した部分だけを抜き出しました。
見てもらうと分かる通り、それほど多くのコードは追加していないのが分かると思います。
どういう処理なのかはコメントとして追記したのでそちらをご覧ください。
大まかに説明だけすると、CommandBufferによって得られたブラー画像がuGUIの位置から見てどの位置なのか、の計算を行い、そのUV値を元にブラー画像からテクセルをフェッチし、それを利用しています。
またImage
に設定されたテクスチャはマスク画像として利用するようにしているので特定の形にくり抜くことができます。
ここで重要な点は、実際に「半透明になっているわけではなく」、あくまでキャプチャした画像の適切な位置を利用することで、あたかも透過しているように見える、というわけです。
最後に
今回のサンプルは自分のiPhoneXでも問題なく動きました。処理負荷的にもそこまで大きくはないかなと思います。
iOSの表現でもブラー処理はよく見ますね。すりガラス風の表現はオシャレに見えるので、ワンポイントのアクセントなどに利用すると質感があがっていい感じです。
今回はuGUIの背景として利用しましたが、すりガラス風の表現はこれ以外にも活用する部分はあると思います。色々試してみるといいかもしれません。