【Unity】URPのBokeh DoFを読み解く【Advent Calendar 12/8】
はじめに
この記事はColorful Palette アドベントカレンダー 12/8の記事です.
株式会社Colorful Paletteでクライアントエンジニアをしている,いちです.普段は主にグラフィックス関連や3D周りの業務をおこなっています.
今回は,「ボケ」表現についてまとめてみました.ボケとは,ピントが合っていない状態のことを指します.UnityのUniversal Render Pipeline(URP)標準のボケ画像を見てみましょう.バケツにピントが合っていますが,前後のオブジェクトはピントが合わず,ぼやけて見えます.UnityのURPでは,標準でボケが表現できる機能が存在しています.各種パラメータで簡単に制御可能ではありますが,内部処理はどういったことをしているんだろう?ということで,コードベースに処理を追ってみます.
また,モバイルゲームにおいて,今回説明する処理をそのまま使うことは処理速度の面においてあまり推奨できません.代わりに,ガウシアンブラーを用いた軽量版のボケ表現がありますので,そちらを使う,もしくは独自の軽量版として開発すると良いと思います.
被写界深度(Depth of Field)とは
被写界深度(以降DoF)とは,写真撮影においてピントを合わせた部分の,ピントが合っているようにみえる範囲を指します.ピントが合う場合,レンズに光が入射し,撮像面で1点に集まります.ただし,ピントが合っていない場合,光が集約せず,円状に結像します.これによって発生するのがいわゆる「ボケ」であり,その円を錯乱円と呼びます.人間は,錯乱円のサイズが眼の解像度よりも大きくなった場合,ボケけていると感じられるようになります.
URPの設定方法
VolumeコンポーネントにDepth Of Fieldを追加することで確認できます.
URPのDoFには,2種類のモードが存在します.Gaussianは,ガウシアンブラーを用いた擬似的なDoFであり,処理内容的にも軽量なDoFです.Bokehは,焦点距離や絞り値などをパラメータにもつDoFであり,よりカメラ構造に従った処理といえます.この記事では,Bokehについてコードを追っていきます(余談になりますが,Bokehは日本語の「ボケ」が語源の英語のようです).
Bokehモードで設定可能なインスペクタのパラメータは以下の通りです.各種パラメータがどのような調整を担うかは今回は省略します.
DoFの実装
それでは,具体的にBokeh DoFの内部処理を見ていきましょう.
検証環境
Unity 2020.3.34f1
render-pipeline.core 10.8.1
render-pipeline.universal 10.8.1
実装 : RenderPass
Gaussian or Bokeh
先述の通り,URPのDoFにはGaussianとBokehの2種類のモードが存在します.PostProcessPass クラスのDoDepthOfField 関数は,実行するモードを決定します.
DoFに限らず,URPのポストプロセス処理はScriptableRenderPassを継承したPostProcessPassにパスを定義します.パスの処理内容は,オーバーライドしたExecute関数に記述します.DoDepthOfField 関数は,Executeで実行されるRender関数で呼び出されます.今回はBokehモードですので,DoBokehDepthOfField関数が実行されます.
DoBokehDepthOfField
DoBokehDepthOfField 関数の全貌は以下の通りです.
前半部分ではシェーダの処理に用いるパラメータを計算し,シェーダプロパティへアクセスして値を更新します.後半では一時テクスチャを定義した後,Bokeh DoFシェーダのパスを実行します.各シェーダの内部処理は後々記述するとして,前半部分のパラメータ計算について見ていきます.
DoBokehDepthOfField - CoCの算出
CoCとはCircle of Confusion,つまり錯乱円を指します.はじめに説明した「ボケ」のサイズともいえます.カメラは,対象物からの光をレンズで屈折させ,結像した光を撮像面上で検知することで,ピントの合った映像を撮影することができます.しかし,撮像面からずれた位置で結像すると,撮像面上では光が一定の幅をもった状態で検知します.この幅がCoCになります.CoCが大きいほどピントがずれており,いわゆる「ボケ」が大きいといえます.
各パラメータの関係性を図示しました.
はじめに,最大CoCを求めます.各ピクセルにおいて,最大CoCに割合を乗算することで,CoCを求めます.最大CoCの計算ですが,ガウスのレンズ結像式と三角形の相似から求めることができます.
ガウスのレンズ結像式は以下のとおりです.
焦点距離と開口径の関係を表すF値(絞り値)ですが,レンズから入る光の量を数値で表しており,焦点距離÷有効口径で計算することができます.今回はF値をパラメータで設定するため,焦点距離とF値から開口径Aを求めます.
次に,青い2つの三角形に着目します.これらは相似であるため,線分比率
$${A:C=d:(b-d)}$$
となります.よって,CoC(=C)は以下のように求めることができます.
b > dの場合,一度光が一点に収束した後拡散し,錯乱円が発生します.b < dの場合,光が一点に収束することなく錯乱円が発生します.また,レンズ結像式と開口径の式を用いて,以下のようにまとめることができます.
この時,Cが最大となるような状態の場合,cすなわち被写体からレンズまでの距離が無限遠に離れているときであり,
このように最大CoCを求めることができます.これにあたるのがmaxCoC変数の計算になります(コード上にも補足説明が欲しいですね).式のaが変数Pにあたります.
次に,画像サイズに適したボケサイズを決定します.
「empirically derived from the ring count(経験的に導き出された)」
…だそうです.
DoBokehDepthOfField 関数前半部分を切り取ります.
フォーカス距離(レンズからピントが合う位置までの距離),最大CoC,画像サイズに合わせた最大CoC半径,画像のアスペクト比をVector4型に格納し,_CoCParamsプロパティの値を更新します.
DoBokehDepthOfField - Bokehカーネルの算出
パラメータ計算の後半部分,Bokehカーネルの算出を見ていきましょう.後ほどシェーダ処理で出てきますが,各ピクセルに対して,求めたカーネルを用いて”ボケ具合”をサンプリングし,カラーを決定します.カーネルとはここでは「絞り羽根でつくられるシャッター形状配列」のことを指し,単位円に内接する正多角形をあらわした配列となります.座標配列計算はPrepareBokehKernel 関数で処理しており,m_BokehKernelに格納します.この値をShaderConstants._BokehKernelのnameIDを指定し,シェーダ側で扱えるようにします.それでは,PrepareBokehKernel 関数を見ていきます.
PrepareBokehKernel 関数では,42点の単位円に内接する正多角形をあらわす座標配列を,m_BokehKernelに格納します.何やら算術演算が多く処理が追いづらそうですが,数式で表現すれば分かりやすくなります.以下は絞り羽根でつくられるシャッター形状,つまり単位円に内接する正多角形状を示しています.
2つの三角形$${OA_1B_1,OA_2B_2}$$の相似 より,
$${OA_1:OA_2=OB_1:OB_2}$$ より,$${OB_1=\frac{OA_1*OB_2}{OA_2}}$$
図より,以下が定義できます.$${OB_2=1,OA_1=cosθ,OA_2=cosα,α=\frac{π}{n}}$$
これより,極座標で以下のようにあらわせます.
青い三角形に着目します.配列に格納している全ての座標に対応する角度は,三角形内における回転角と,現在の角度がどの三角形に含まれるかを定義することで求めることができます,絞りばねの形状を正n角形とすると,インデックスm(1以上n以下)の三角形に含まれている場合,mは以下のようにあらわせます.
これにより,全ての角度を考慮した角度は,
となります.絞り羽根の曲率cを加え,指定座標に対する長さである$${OB_1}$$は以下の通りです.
ユーザは,羽根の枚数n,回転βをパラメータで指定し,カーネルを算出します.$${OB_1}$$から指定座標を求めます.
これを,m_BokehKernelに格納します.
PrepareBokehKernel 関数でおこなっている処理の全容は以上の通りです.
実装 : Shader
シェーダ処理は,5パスで構成されています.5パスとはいえ,Bokeh DoFはそれなりに重い処理になります.どこが処理速度上ボトルネックになっているかも含め,追っていければと思います.それぞれのパスの処理内容は,以下の通りです.
pass0 : CoCの算出
pass1 : ダウンサンプリングとボケの影響範囲を算出
pass2 : カーネルを用いたボケのサンプリング
pass3 : ぼかしの強度を高めるブラー処理
pass4 : ソースとボケテクスチャをサンプリングし,重みづけをおこない最終カラーを決定
pass0 : FragCoC
FragCoCでは,錯乱円を計算します.先ほど最大CoC値を計算し,_CoCParamsに格納しました.この値をMaxCoCとマクロ定義し,上記で記した式を計算して各ピクセルに適したCoCを求めます.CoCは最終的に0~1に正規化され,0.5が完全にピントが合った状態,0.5以下は前景ボケ,0.5以上は後景ボケと捉えることができます.
FrameDebuggerでpass0の出力を確認してみると,ピントの合う範囲がわかります.前景ボケは0.5以下なので黒に,後景ボケは0.5以上なので白になります.
pass1 : FragPrefilter
ここで,大まかにBokeh DoFを説明すると,ソーステクスチャとブラーをかけたテクスチャのうち,CoCを基準に周囲数点分をサンプリングし,重みづけよりカラーを決定します.FragPrefilterでは,ブラーテクスチャを作成するために,ダウンサンプリングをおこないます.また,次の処理のためにCoCを符号付き正規化します.
はじめに,周囲4点の双一次補間(バイリニア補間)をおこないます.プラットフォームごとに処理が分かれており,Gather(HLSL組み込み関数)が利用可能な場合はGather関数を用います.これはフラグメントシェーダが4ピクセル同時並列処理する特徴を活かした高速化処理といえます.UVをずらし,4点分テクスチャアクセスするよりも高速のようです.Gather関数が利用不可の場合,地道にサンプリングをおこないます(#else以降処理).Shader Model 5.0 以降をターゲットとするシェーダーでは,各オフセット値の最下位 6 ビットが符号付きの値として受け入れられ,[-32..31] の範囲が得られます.しかし,MetalなどのShader Model シェーダーの場合,オフセットは [-8…7] の整数である必要があります.
FragPrefilter後半に入ります.ここではサンプリングした4点のカラー値を合成し,ブラーのカラー値を返します.
COC_LUMA_WEIGHTINGシェーダバリアントによって合成処理が変わります.
COC_LUMA_WEIGHTINGが有効になるかどうかは,オブジェクトがHDRカラーを用いているかどうかで変わります.COC_LUMA_WEIGHTINGが無効の場合,単純平均をとります.HDRカラーを用いている場合,1を超える値を取得してしまうため,単純平均でも大きな値を返してしまいます.これはちらつきの要因となります.よって,各チャンネルの輝度値を用いて重みづけをおこないます(輝度値を用いず処理すると彩度が落ちてしまい,見栄えが悪くなってしまいます).また,CoCも重みづけの要因として加えます.
計算式は以下の通りです.
次に,4点の最大CoCに対してMaxRadiusで乗算し,サイズを決定します.MaxRadiusはempirically derived from the ring countな値です.half cocは前景後景問わず最大サイズのCoCを求めています.ただし前景は負,後景は正であわらされます.
最後にRGB値を,CoCを引数としたsmoothstepを用いて補間をおこないます(_SourceSize.w=1/screenHeight).この補間が何を意味するかというと,テクスチャフィルタリングにおけるCoC計算が,アーティファクトにより0より大きい値を返してしまうことを防ぐ処理となっています.また,バイリニア補間によりブレンドしたカラーの精度が変わらないよう,テクスチャフォーマットは
GraphicsFormat.R16G16B16A16_SFloat
となっています.
pass1の出力は以下の通りです.見た目上バイリニア補間と変わりませんが,アルファ値が変化していることに注意してください.
pass2 : FragBlur
FragBlurの処理自体は簡素で,まず前景ボケ具合(=nearAcc)と後景ボケ具合(=farAcc)を定義します.その後,事前に算出したカーネルを用いて,Accumulate関数で周囲のボケ具合をサンプリングします.この周囲のボケ具合を用いて,現ピクセルのカラー値を決定します.
Pass2: FragBlur - Accumurate関数
Accumulate(蓄積する)関数ですが,カーネルの各ポイントからサンプリングし,前景CoC,後景CoCを決定します.コメントごとに追っていきます.
half farCoC = max(min(samp0.a, samp.a), 0.0);
ここで,farCoCは正である,つまり遠景CoCを取得しています._SourceTexのアルファ値には,1つ前のPassの出力値である,符号付きCoCが格納されていました.前景は負,後景は正であわらされるため,正の値をとることで遠景CoCであることがわかります.
const half margin = _SourceSize.w * _DownSampleScaleFactor.w * 2.0;
half farWeight = saturate((farCoC - dist + margin) / margin);
half nearWeight = saturate((-samp.a - dist + margin) / margin);
farCoCは上述した通りですが,-samp.aも同様,前景CoCを取得しています.farWeightでは前景CoCの場合ほぼ0になり,nearWeightでは後景CoCの場合ほぼ0になります.distは中心点(samp0)から正多角形座標カーネルの指定座標(samp)までの長さです.中心点から遠くにいくほど影響値が小さくなるよう減算しています.
nearWeight *= step(_SourceSize.w * _DownSampleScaleFactor.w, -samp.a);
Accumurate関数で周囲42点分の前景後景重み係数を求めたところで,FragBlurの後半を見ていきます.
重み係数をfarAcc,nearAccに掛け合わせます.その後,nearAccのアルファチャンネルを正規化します.π乗算はおそらく,正規化後の前景ボケの効果を高めるためかと思われます.最後にnearAcc.aを元に前景後景RGBを割合合成し,前景後景ボケを考慮したカラー値を決定します.
ここまで紐解いてみるとわかる通り,FragBlurは前景後景の重み係数を求めるために,1ピクセルあたり42点分のテクスチャサンプリング,計算処理が走ります.この処理がとても重い処理であり,モバイルゲームに採用されない原因の1つでもあります.
出力は以下の通りです.前景後景のボケがかかっていることがわかります.
pass3 : FragPostBlur
FragPostBlurでは,3x3のTent filterを適用します.Tent filter適用前は,ボケ形状が比較的はっきり残ってしまうことがあり,これを和らげるために,ブラー処理をおこないます.
Pass3の出力とPass4の出力の違い(3x3のTent Blur適用前後)を見てみましょう.左がPass3(Tent Blur適用前),右がPass4(Tent Blur適用後)の出力画像です.違いが分かりづらいですね…左上の柱に注視すると,ボケが柔らかくなっている感じがしそうです.
pass4 : FragComposite
最終Passになります.ここでは,ソース(ボケなし)テクスチャとボケテクスチャを補間し,最終カラーを決定します.
コアとなる処理は以下の部分です.
color = lerp(color, half4(dof.rgb, alpha), ffa + dof.a - ffa * dof.a);
SourceTex…BokehDoF適用前の未処理テクスチャ
_DofTexture…FragPostBlur適用後テクスチャ
color(_SourceTex)とdof(_DofTexture)において,
ソース(color)と後景ボケ(dof)を線形補間
1で補間した値と前景ボケ(dof)を線形補間
をおこなっています.
ソース(ボケなし)と後景ボケはffaで補間します.
1の合成値 $${C_1}$$は,線形補間より,
1で補間した値と前景ボケはdof.aで補間します.
2の合成値$${C_2}$$ は,線形補間より,
上記より$${C_1}$$を代入し,
上記のコアとなる処理と比較しても,同様の処理をおこなっていることがわかります.
以上により,Bokeh DoFを用いた被写界深度が画像として出力されます.
これで無事DoFが表現できました!
おわりに
UnityのURPで標準実装されているBokeh DoFを見ていきました.ボケが発生する原理からカメラの構造,グラフィックス技術と,複合的な知識が要求される内容でした.この辺りはUnityに限らず,CGで物理ベースドな表現をするには切っても切り離せない分野ですので,根気よく知識をつけていきましょう!
Colorful Palette アドベントカレンダー は12/25までの平日+α更新となっているのでぜひ引き続きご覧ください!