ディザリングの実装

ディザリング

トーンマップやガンマ補正を行うと, Float精度のHDRテクスチャから8bit精度のSDRテクスチャへのマッピングができるわけですが, シーンによっては精度不足であったりしてバンドが見えてしまうことがあります。

f:id:hikita12312:20170919235626p:plain:w600

上の図は,画面の左から右にかけて,なめらかに0.0~1.0で明るさを変化させているシーンに対して, Logトーンマップを適用した結果です。精度不足なのかバンドが微妙に見えています。

f:id:hikita12312:20170919235750p:plain

SDRテクスチャは256分割の分解能しかないので,シーンによってはマッピング後に階段状に見えてしまうことがあります。

そこで今回実装するのがディザリングです。

https://en.wikipedia.org/wiki/Ordered_dithering

Bayer Matrix

以下のような行列を4x4のBayer行列と呼びます。

0/168/162/1610/16
12/164/1614/166/16
3/1611/161/169/16
15/167/1613/165/16

実際行列演算をするわけではなくて,このタイルをスクリーン全体に敷き詰めて使います。
あるピクセルのトーンマップ後のカラーを256分割の整数値{n}で表したとき, その小数部分が対応する位置のBayer行列の値よりも大きいか小さいかを比較して, 出力色として{n}を採用するか,{n+1}を採用するかを判断します。

シェーダー内部でif文など条件分岐で,この比較を行うと遅くなるので,このBayer行列を4x4のテクスチャに変換します。

f:id:hikita12312:20170920000806p:plain:w300

あとはシェーダーで,Bayer行列テクスチャをWRAPサンプラでフェッチしながら,適当にスケールして足し算することでディザリングができます。

// SCREEN_SIZE : スクリーンのピクセルサイズ
// DITHER_TEXTURE_SIZE : Bayer行列テクスチャサイズ. DITHER_TEXTURE_SIZE=4
// DITHER_SCALE : スケール.8bit整数ならば DITHER_SCALE=1/255
const float3 ditherMatrix = SampleLod0(g_BeyerMatrixTexture, g_BeyerMatrixSampler, uv*SCREEN_SIZE / DITHER_TEXTURE_SIZE).rgb;
outputColor.rgb += ditherMatrix * DITHER_SCALE - DITHER_SCALE / 2.0f;

結果

f:id:hikita12312:20170919235626p:plain:w300
ディザリング無し
f:id:hikita12312:20170920001316p:plain:w300
ディザリング有り

左右で並べてみると,よくわかりませんが,それは縮小して見ているからです。 フルサイズで比較すると,バンドが軽減されているのがわかります。

f:id:hikita12312:20170920001423p:plain:w600

ディザリング有り/無しの結果の一部分をトリミングして,適当にレベル補正をした結果の比較です。 バンド付近の濃淡がドットで表現されています。

カラーコレクション

カラーコレクション 

今度は最低限のカラーコレクション(ColorCorrection)を実装します。 カラーコレクションを行うことも踏まえて,今までのフローをおさらいします。

f:id:hikita12312:20170913225016p:plain

カラーコレクションを行う場合は,トーンマッピング前後における,HDR空間({0\sim\infty})かSDR空間({0\sim1})でのどちらかで行うことになると思います。 HDR空間でカラーコレクションを行うと,1.0以上の高輝度成分もトーンマッピングで良い感じにサチって白飛びなどが目立たなくなりますが, 非線形なトーンマッピング変換によって,色変換が直感的ではなくなって色味が変わったものになります。 SDR空間でカラーコレクションを行えば直感的ですが,白飛びが起こりやすくなります。

本記事では,SDR空間でカラーコレクションを行うこととします。

明度変換

{ \displaystyle
b : \mathrm{brightness} \\
\begin{eqnarray}
\left(\begin{array}{c}r'\\g'\\ b'\\ 1\end{array}\right)=
\left(  
\begin{array}{cccc}
b & b & b & 0 \\
b & b & b & 0 \\
b & b & b & 0 \\
0 & 0 & 0 & 1
\end{array}
\right)
\left(\begin{array}{c}r\\ g\\ b\\ 1\end{array}\right)
\end{eqnarray}
}
f:id:hikita12312:20170914100150p:plain
{b = 0}
f:id:hikita12312:20170914100214p:plain
{b = 2.0}

色変換を行うときに4x4の行列を用いて線形変換を行う方法を利用します。 下のサイトの行列変換を参考にしました。

https://docs.rainmeter.net/tips/colormatrix-guide/

見たままですが,RGB空間に対して全ての成分を{b}倍にスケールすることで,明るさを変換できます。

彩度変換

{ \displaystyle
s : \mathrm{saturation} \\
{\bf W} = (0.298912, 0.586611, 0.114478) \\ 
{\bf S} = (1-s){\bf W} \\
\begin{eqnarray}
\left(\begin{array}{c}r'\\g'\\ b'\\ 1\end{array}\right)=
\left(  
\begin{array}{cccc}
S_r + s & S_g & S_b & 0 \\
S_r & S_g + s & S_b & 0 \\
S_r & S_g & S_b + s& 0 \\
0 & 0 & 0 & 1
\end{array}
\right)
\left(\begin{array}{c}r\\ g\\ b\\ 1\end{array}\right)
\end{eqnarray}
}
f:id:hikita12312:20170914100150p:plain:w300
{s = 0}
f:id:hikita12312:20170914100504p:plain:w300
{s = 2.0}

{\bf W}の重みは,輝度を算出するときに利用されるNTSC係数です。 https://ja.wikipedia.org/wiki/NTSC ネットで調べると,G成分が0.7ぐらいの値のものが出てくることがありますが,それはガンマ補正を考慮した結果の重みで, 今回はガンマ補正前なので,この重みで輝度を算出します。
s=0のときが,いわゆるグレースケールとなります。sでグレースケールと入力色の線形補間で彩度変換を表現しているようです。

コントラスト変換

{ \displaystyle
c : \mathrm{contrast} \\
t = (1-c)/2 \\
\begin{eqnarray}
\left(\begin{array}{c}r'\\g'\\ b'\\ 1\end{array}\right)=
\left(  
\begin{array}{cccc}
c & 0 & 0 & t \\
0 & c & 0 & t \\
0 & 0 & c & t \\
0 & 0 & 0 & 1
\end{array}
\right)
\left(\begin{array}{c}r\\ g\\ b\\ 1\end{array}\right)
\end{eqnarray}
}
f:id:hikita12312:20170914100150p:plain:w300
{c = 0}
f:id:hikita12312:20170914101003p:plain:w300
{c = 1.5}

cで灰色と入力色の線形補間をしているようです。

単色フィルタ

{ \displaystyle
{\bf C} : \mathrm{color} \\
s : \mathrm{scale} \\
{\bf W} = (0.298912, 0.586611, 0.114478) \\ 
\begin{eqnarray}
\left(\begin{array}{c}r'\\g'\\ b'\\ 1\end{array}\right)=
\left(  
\begin{array}{cccc}
(1-s)+sW_rC_r & sW_gC_r & sW_bC_r & 0 \\
sW_rC_g & (1-s)+sW_gC_g & sW_bC_g & 0 \\
sW_rC_b & sW_gC_b & (1-s)+sW_bC_b & 0 \\
0 & 0 & 0 & 1
\end{array}
\right)
\left(\begin{array}{c}r\\ g\\ b\\ 1\end{array}\right)
\end{eqnarray}
}
f:id:hikita12312:20170914100150p:plain:w300
{s = 0}
f:id:hikita12312:20170914101105p:plain:w300
セピア化

{\bf C}と,その色への変化度合い{s}が与えられたときの,単色への変換です。 {\bf C}が茶色っぽい色の場合は,いわゆるセピアトーンになります。 {s=0}のときは,単位行列になり,{s=1}のときは,マスク色{\bf C}と輝度の乗算となります。

色相変換

{ \displaystyle
\begin{eqnarray}
\left(\begin{array}{c}r'\\g'\\ b'\\ 1\end{array}\right)=
R(\theta)
\left(\begin{array}{c}r\\ g\\ b\\ 1\end{array}\right)
\end{eqnarray}
}
f:id:hikita12312:20170914100150p:plain:w300
{\theta = 0.0^\circ}
f:id:hikita12312:20170914101146p:plain:w300
{\theta = 180^\circ}

{R(\theta)}は軸{\frac{1}{\sqrt{3}}(1,1,1)}を中心とした回転行列です。
RGB空間を各軸の中心方向の軸に対して回転をさせれば,色相が変換できます。

LUT

今までの変換は,全て4x4の行列を利用した線形変換でした。 しかしながら,実際に色変換を行いたい場合は非線形の変換を多数行うことになるでしょう。 非線形変換を行う為にシェーダーに変数を追加したり,シェーダー自体を書き換えたりすれば,シェーダーバイナリは膨れ上がりますし,計算コストも増大していく一方です。

そこで,16x16x16のような立方体サイズの3次元テクスチャを用意して,その各軸を0から1までのRGBと捉え, SDR空間において入力色の座標から線形補間しながら出力色をフェッチするLUT(Look Up Table)を利用します。 事前に様々な変換を施したLUTを作成しておくことで,再現性が高く,高速に色変換が可能です。 もちろん,サイズ16ではなく,サイズ32,64などに増やしていけば精度が上がります。

本来はフォトショなどでLUTを作成しますが,UnrealEngineのドキュメントにテスト用LUTがあったので,拝借します。

https://docs.unrealengine.com/latest/JPN/Engine/Rendering/PostProcessEffects/ColorGrading/index.html

f:id:hikita12312:20170914102504p:plain

このテクスチャを3Dテクスチャに変換して,線形補間した結果です。

f:id:hikita12312:20170914100150p:plain:w300
変換前
f:id:hikita12312:20170914102607p:plain:w300
変換後

光芒の実装

光芒

グレア表現として,ブルームを実装しましたが これだけでは寂しいので光芒(スター)を実装してみます。

光芒は物理的には,カメラの絞り羽根による光の回折現象によって特定の方向に光が伸びていくような現象のことを呼びます。
https://www.bhphotovideo.com/explora/photography/tips-and-solutions/create-compelling-star-effects-sun-stars-starbursts-photos

ピンポンブラー

今回の実装はピンポンブラーを利用して表現します。

Masaki Kawase, Frame Buffer Postprocessing Effects in DOUBLE-S.T.E.A.L (Wreckless), GDC 2003
http://www.daionet.gr.jp/~masa/archives/GDC2003_DSTEAL.ppt (スライド)

まずは光芒のうち,一本の線を作ることを考えます。
簡単の為に,今回はX-方向に延ばすことにします。 このときに,4点({i=0,1,2,3})をサンプリングするのですが,この時のサンプリングする位置{r_p(i)}と重み{w_p(i)}を以下のように決めます。

{ \displaystyle
\begin{eqnarray}
p &=& 0,1,2,\cdots \\\
i &=& 0,1,2,4 \\\
\\\
r_p(i) &=& 4^p i\\\
w_p(i) &=& a^{r_p(i)} = a^{4^p i}\\\
\end{eqnarray}
}

{p}は繰り返しパス数で,サンプリングを繰り返す度に0から1,2,3とインクリメントされていきます。 サンプリングを行った結果のテクスチャを,またサンプリングに利用することで重みを延伸していきます。

f:id:hikita12312:20170910182042p:plain

このように,直前のブラーの結果を利用しながら繰り返してブラーを適用することを,ピンポンブラーとかいうらしいです。 サンプリング数に対して,指数的にブラーサイズが伸びていくので,かなり頭の良い方法だと思いました。

このサンプリングはX-方向に延ばす場合ですが,距離{r_p(i)}を二次元にして,適宜回転させれば,任意の方向にブラーを延ばすことが出来ます。

f:id:hikita12312:20170910183306p:plain

上が適当に{a}と繰り返し数を決めて4本の光芒を適用させた結果です。前回作成したブルームも少しだけ入っています。 長く光が伸びているのがわかります。

スペクトルテーブル

これだけだとつまらないので,分光的な表現をしてみたいと思います。
前準備として,波長からRGBカラーに変換するテーブルを作成します。

http://www.cvrl.org/ciexyzpr.htm

このサイトから,波長とXYZ色空間での色のテーブルを作ります。

static const float s_aTable[] =
{
    0.16638f,0.0183f,0.81532f,  // 390nm
    0.16595f,0.01874f,0.81531f, // 395nm
    0.16499f,0.01827f,0.81673f, // 400nm
    0.16393f,0.01718f,0.81888f, // 405nm
    .....
}

このXYZ空間から,R=830nm, G=527nm, B=390nmを原色と選んだ色空間へのマッピングを行います。 ちなみに白色点はsRGBと同じく,(x,y)=(0.312,0.329)を利用しました。

https://www.t-kougei.ac.jp/activity/research/pdf/vol36-1-09.pdf

XYZ空間から任意の色空間への変換方法は以下のゲームのための色彩工学のスライドを参考にしました。

https://www.slideshare.net/nikuque/color-science-for-gamesjp

最終的に計算されたXYZ空間からスペクトルRGB空間への変換行列を記載します。

{ \displaystyle
\begin{eqnarray}

\left(
\begin{array}{c}
r \\\
g \\\
b
\end{array}
\right)
=
\left(  
\begin{array}{ccc}
1.78611469 & -0.585037410 & 0.0115048392 \\
-0.308209479 & 1.46176445 & -0.0287458934 \\
-0.357569426 & 0.0865773335 & 0.934579492
\end{array}
\right)
\left(
\begin{array}{c}
x\\\
y\\\
z
\end{array}
\right)
\end{eqnarray}
}

で,このテーブルを256x1サイズのテクスチャにマップした結果がこの画像です。 f:id:hikita12312:20170910185904p:plain 右端と左端は本来は完全に赤色と青色になるべきなのですが,今回は周期的に滑らかに繋がって欲しかったので, 適当に端が390nmと830nmの色の平均(=紫)となるように,滑らかに補間しています。

ちなみに256x1だけではなく,128x1,64x1などのテーブルもミップマップとして作成しておくと,この後の光芒の分光表現を作るときに滑らかに補間されて便利です。

スクリーンスペース光源距離マップの作成

スペクトルテーブルをマッピングするために,各光源からの距離を求めなければなりません。 まずは,光源と判断される明るさを持ったピクセルを1,それ以外を0とするマスクを作成します。

f:id:hikita12312:20170910191550p:plain

このマスクは,前回のブルームを実装したときに作成した,高輝度テクスチャのA成分に格納しておきます。 あとは,そのまま高輝度テクスチャをソースに,光芒を作るためのピンポンブラーをRGBA各チャンネルで同じ重みで行います。

すると,光源マスクされたアルファチャンネルも光芒の方向にボケるわけですが,ボカシのウェイトの関係から, 光源からの距離{r}離れた場所のAチャンネルの強度{\alpha}は"大体"以下のような関係にあるような気がします。
(ちょっと怪しい)

{ \displaystyle
\begin{eqnarray}
\alpha(r) &=& a^{r}
\end{eqnarray}
}

これの逆関数を求めると

{ \displaystyle
\begin{eqnarray}
r &=& \frac{log(\alpha(r))}{log(a)}
\end{eqnarray}
}

{\alpha = a^{r}}の関係を認めてしまえば,ブラー結果の副産物から距離っぽい値を計算することができます。

f:id:hikita12312:20170910192329p:plain

上の図は光芒と,アルファチャンネルから計算された距離{r}を利用して{\mathrm{abs}\big(\sin{(2\pi r/100)}\big)}として,縞模様を作った結果です。 なんとなく等間隔に縞模様が並んでいて,光源からの距離っぽい値になっている気がします。

この距離っぽい値と,スペクトルテーブルを利用して,光芒を着色します。

f:id:hikita12312:20170910193244p:plain

それっぽい感じの着色はできましたが…
うーん,なんか微妙です。修正の余地あり。

オートフォーカスの実装

オートフォーカス

被写界深度表現を実装したので, 現実のカメラとかスマホにあるオートフォーカス機能を実装してみようと思います。
Depthテクスチャから中央付近のピクセルをサンプリングすれば,カメラから画面中心の物体までの距離が取得できるので, この距離に向かって滑らかにフォーカス距離を変化させれば実現できます。

PID制御

いろいろやり方はあると思うんですが,今回はPID制御を使ってみたいと思います。

https://ja.wikipedia.org/wiki/PID%E5%88%B6%E5%BE%A1

あるフレームでのフォーカス距離を{d_0(t)}であるとき, 測距された目標とするフォーカス距離を{d_{\mathrm{target}}(t)}とします。 この時1フレーム後,{\Delta t}だけ経過したときにどれだけ変化させれば良いかということを, PID制御の表式から以下のように定めます。

{ \displaystyle
\begin{eqnarray}
e(t) &=& d_{\mathrm{target}}(t) - d_0(t) \\\
\Delta d_0(t) &=& K_p e(t) + K_i \int^t_0 e(\tau) d\tau + K_d \frac{d}{dt}e(t) \\\
d_0(t+\Delta t) &=& d_0(t) + \Delta d_0(t)
\end{eqnarray}
}

要は,目標値までのフォーカス速度を,目標値との差{e(t)}の比例項{K_p},積分{K_i},微分{K_d}で定めようということになります。

これをコード上で表すとこんな感じです。

float PIDController::GetNextOperationValue(const float& value, const float& targetValue, const float& deltaFrameSec) noexcept
{
    const float e = targetValue - value;
    const float dedt = ((e - m_e0) / deltaFrameSec);
    m_Integral += e*deltaFrameSec;
    m_e0 = e;
    return (m_Kp * e + m_Ki * m_Integral + m_Kd*dedt);
}

対数空間でのPID制御

何も考えずにPID制御をそのまま実装すると,目標値やフォーカス距離によっては,一瞬だけフォーカス距離{d_0(t)}が0以下の負の値となってしまう場合があります。 負のフォーカス距離は当然未定義なので,その瞬間だけ被写界深度処理はバグってしまいます。 これを防ぐために,対数距離をを利用する修正を行いました。

{ \displaystyle
\begin{eqnarray}
e(t) &=& \log{\big(d_{\mathrm{target}}(t) \big)} - \log{\big(d_0(t)\big)} \\\
\Delta d_0(t) &=& K_p e(t) + K_i \int^t_0 e(\tau) d\tau + K_d \frac{d}{dt}e(t) \\\
d_0(t+\Delta t) &=& \exp{\bigr\{ \log{\big( d_0(t)\big)} + \Delta d_0(t)  \bigr\}}
\end{eqnarray}
}

対数空間なら,制御結果がいくら負になっても,0に漸近するだけとなります。 あと,対数空間でオートフォーカスを行ったほうが,なんとなく滑らかにフォーカスしていくような気がします。(気のせいかもしれません)

結果

f:id:hikita12312:20170905011832g:plain

まあまあ良いんじゃないでしょうか。 {K_p=4.0, K_i=6.0, K_d=0.01}の値を利用しています。この辺の値の決め方もいろいろあるらしいんですが,とりあえず勘です。

ちなみに,オートフォーカス処理だけでなく,露光処理を行うときにも, 距離の代わりに現在の露光値と平均輝度から計算される目標露光値を利用すれば,人間の目の明順応や暗順応のような表現を実現できます。

被写界深度表現の実装

被写界深度とは

今度はカメラで撮った写真のような,ボケのある表現を実装してみたいと思います。 UnrealEngineのドキュメントを見ると,どんな感じの表現なのか掴みやすいでしょう。
Unreal Engine | 被写界深度

錯乱円の公式

ある点からレンズを通ってフィルムに投影される円の大きさ(錯乱円,Circle of Confusion,CoC)を計算します。
https://en.wikipedia.org/wiki/Circle_of_confusion
詳しい計算はWikipedia。レンズの公式から導けます。

{ \displaystyle
\begin{eqnarray}
f &=& \mathrm{Focal \ Length}\\\
F &=& \mathrm{Fnumber}\\\
d_0 &=& \mathrm{focus \ length}\\\
\epsilon &=& \mathrm{pixel \ size}\\\
\\\
\mathrm{CoC}(d) &=& \frac{|d-d_0|}{d}\frac{f^2}{F(d_0-f)}\frac{1}{\epsilon}
\end{eqnarray}
}


{f}焦点距離,{F}はカメラのF値,{d_0}はカメラのフォーカス距離です。
錯乱円(以下CoC)を求める式としては本来必要ないですが,現実のカメラの撮像素子の画素サイズを表す{\epsilon}も定義しておきます。 実際に被写界深度処理をかけるテクスチャの1pixelを,仮想的にカメラの撮像素子とみなすということに対応します。 {\mathrm{CoC}(d)\leq1}のときは,1pixelの中にCoCが収まるので,ボケない(=被写界)と考えることができて, {\mathrm{CoC}(d)>1}の場合はそのCoCのサイズに応じた直径のブラーをかけることで,被写界深度表現を行います。

f:id:hikita12312:20170902185332p:plain:w300
元画像
f:id:hikita12312:20170902200259p:plain:w300
CoCマップ

CoCを実際に図示した結果です。前ボケのCoCは負の値を格納していたり,後ボケの1.0以上の大きさのCoCが格納されていたりするので,図示するとサチってしまってあまり意味はないのですが…

ボケフィルタによるギャザーベース被写界深度表現

CoCのサイズに従って,前回ブルームで行ったようなガウスブラーをかければ,距離に従ったボケの表現が可能です。
幾つかの代表的なCoCサイズに対応するガウスブラー結果をフィルタとして用意してます。 実際のピクセルの深度値からCoCサイズに変換し,CoC値でフィルタ間のボカされたテクセルをブレンドする方法で被写界深度表現を実装してみます。

f:id:hikita12312:20170902183551p:plain

この図は,実際にCoCごとのフィルタを作成して,それを補間して被写界深度表現を行う際の模式的な図です。 今回は補間を行うときに,smoothstepによる三次補間を行っています。 線形補間でも良さそうですが,フィルタ間が折れ線グラフ的になると,微分値が不連続でなんとなくカクカクした感じに見えたので,smoothstepを使っています。

大きいCoCサイズを表現するフィルタでは,大きなブラーをかけることになり,画像の情報量を減らすということに対応するので,画像を縮小して負担を減らす方法が有効です。 1/2サイズのバッファを基本的に利用していますが,大きいブラーをかけるフィルタでは,1/4サイズぐらいまでの縮小バッファを利用して高速化しています。

f:id:hikita12312:20170902185025p:plain

上の画像が実際に被写界深度処理を適用したものです。前ボケにフィルタを3枚,後ボケにフィルタを4枚使っています。

f:id:hikita12312:20170902185332p:plain
DoF:OFF
f:id:hikita12312:20170902185025p:plain
DoF:ON

ONとOFFを比べてみるとわかりやすいですね。

後ボケの滲みの軽減

実はさっきの被写界深度の実装はダメダメです。 例えば,凄くF値を小さくして,ボケボケにした状態で,明るいオブジェクトにピントを合わせてみます。

f:id:hikita12312:20170902190243p:plain

ピントが合っているピクセルの色が,後ボケに滲み出ていて,本来存在しないはずの色が後ボケに乗っています。
これは,ガウスブラーにおいてサンプリングをするときに,本来光学的に遮蔽されてブレンドされない色が乗っていることが原因です。
そこで,後ボケフィルタにおいて,手前にあると見なせるCoCの値のピクセルについてはマスクをするという処理を行います。

f:id:hikita12312:20170902192025p:plain:w400

マスクをされたテクセルはサンプリングされず,ガウスブラーの正規化する際の重みにも反映されません。 なぜその1つ前のフィルタの距離からマスクを行うかというと,ボケを補間して合成をするときに,中間の距離の情報がなくなってしまって,黒くなってしまうからです。

f:id:hikita12312:20170902192650p:plain

後ボケのマスクを行った結果が上の画像です。後ボケの滲みが軽減されました。

f:id:hikita12312:20170902190243p:plain
後ボケマスク:OFF
f:id:hikita12312:20170902192650p:plain
後ボケマスク:ON

前ボケのエッジブラー

この章はでの実装は,少し遅いのと,もっとキレイにやる方法がありそうなので,まだまだ検証中です(2017/09/02)

前ボケも実はダメダメです。

f:id:hikita12312:20170902193251p:plain

前ボケのブラーされた結果と,ピントの合ってボケていない領域の境界がとてもハッキリ見えています。もっとぼんやりとボケて欲しいと思います。
今回のガウスブラーのようなブラーはギャザーベースのブラーと呼ばれていて, 周囲のピクセルのをサンプリングして集めて,出力するピクセルの色を決めるという手法においてのボケの境界は,一つの問題です。

エッジをボカシたい,ということで,とりあえずSobelフィルターを利用して,エッジ検出をします。

10-1
20-2
10-1

{\Delta x}
121
000
-1-2-1

{\Delta y}


このエッジをマスクとみて,この範囲の前ボケエッジをボカスようにガウスブラーを適用します。

マスクはガウスブラー範囲よりも大きくとらなければ行けないのですが,Sobelフィルターをそのまま使うと,検出されるエッジの太さは1ピクセルにしかなりません。 そこで,本来はSobelフィルタのカーネルは3x3サイズですが,適当にカーネル間のテクセルサイズを5テクセルぐらいに拡大してサンプリングをしました。(上の画像)

前の章までの,ボケフィルタの合成処理の後に,エッジへのガウスブラーを適用したものが以下の画像です。

f:id:hikita12312:20170902195649p:plain

f:id:hikita12312:20170902193251p:plain
前ボケブラー:OFF
f:id:hikita12312:20170902195649p:plain
前ボケブラー:ON

終結

後ボケマスク,前ボケブラーを適用した被写界深度表現の結果です。

f:id:hikita12312:20170902195926p:plain

f:id:hikita12312:20170902185025p:plain
後ボケマスク,前ボケブラー:OFF
f:id:hikita12312:20170902195926p:plain
後ボケマスク,前ボケブラー:ON

トーンマップいろいろ

はじめに

今回はトーンマップをいろいろ作って遊んでみます。
前回の続きです。 前提として,

などを行っています

トーンマップ

線形マップ

{ \displaystyle
f(x) = x
}
f:id:hikita12312:20170826234130p:plain ただ入力をそのまま出力しただけです。もはやトーンマップではないです。
一応ガンマ補正やらブルーム処理の結果のリファレンスとして。

Reinhard

{ \displaystyle
f(x) = \frac{x}{1+x} \Bigl( 1+ \frac{x}{L_w^2}\Bigr)
}


f:id:hikita12312:20170826235136p:plain
{L_w=2.0}
f:id:hikita12312:20170826235152p:plain
{L_w=\infty}

前回も登場したReinhard関数。
http://www.cs.utah.edu/~reinhard/cdrom/
前との違いは,白色点(1,1,1)にマップする輝度{L_w}を指定するような形になっているところ。 {L_w=\infty}のときが前回と同じ関数です。{L_w=\infty}のときの関数系と,線形トーンマップを合成したような形となっています。

exp

{ \displaystyle
k = \log{(1/255)} / L_w \\
f(x) = 1 - \exp{(kx)}
}


f:id:hikita12312:20170826235827p:plain
{L_w=2.0}
f:id:hikita12312:20170826235839p:plain
{L_w=8.0}

1.0でサチるような関数を考えた結果,信頼と実績の指数関数を使ってみました。
これも,白色点にマップする輝度{L_w}を考えて設計しています。 8bitのSDRテクスチャにマッピングすると考えて,輝度が254/255以上となるような値を白色と定義しています。

log

{ \displaystyle
k = (e-1) / L_w \\
f(x) = \log{(1 + kx)}
}


f:id:hikita12312:20170827000257p:plain
{L_w=2.0}
f:id:hikita12312:20170827000306p:plain
{L_w=8.0}

指数関数があるなら,対数関数でトーンマップしてもいいだろうと作ってみたやつです。

ACES Filmic Tonemapping Curve

{ \displaystyle
a = 2.51 \\
b = 0.03 \\
c = 2.43 \\
d = 0.59 \\
e = 0.14 \\
f(x) = \mathrm{saturate}\Bigl( \frac{x(ax+b)}{x(cx+d)+e} \Bigl)
}


f:id:hikita12312:20170827000754p:plain https://knarkowicz.wordpress.com/2016/01/06/aces-filmic-tone-mapping-curve/
フィルム調なトーンマップです。 Ucharted2で使われていたらしいACESトーンマップを使いやすくしたための近似らしいです。
指数関数とかReinhardと違って,上に凸というわけではなく,輝度の小さい領域では立ち下がっていて, 暗い部分はより暗く,明るい部分はより明るくといったような,微妙にS字型となっているトーンマップです。

RGB or 輝度

今まで例に出していたトーンマップの適用結果の画像は,RGBの各チャンネル毎にトーンマップ関数を適用するRGBベースのものです。 ですが,少しだけ修正してあげれば輝度ベースで適用することも可能です。

// トーンマップの適用関数
// @param color 入力値
// @param exposure 露光値
// @param whiteLuminance 白色(1,1,1)にマッピングされる輝度
float3 Tonemap(int funcType, float3 color, float exposure, float whiteLuminance)
{
#ifdef LUMBASE
    // 輝度ベースのトーンマップのとき
    const float luminance = GetLuminance(color);
    return color / luminance * TonemapFunction(funcType, luminance*exposure, whiteLuminance);
#else
    // RGBベースのトーンマップのとき
    return TonemapFunction(funcType, color*exposure, whiteLuminance);
#endif
}

輝度に対して,トーンマップをかけると各チャンネル間の差を変えることなく彩度を保ってマッピングができます。

Reinhard

f:id:hikita12312:20170826235152p:plain:w300
RGB
f:id:hikita12312:20170827001544p:plain:w300
Luminance

ACES

f:id:hikita12312:20170827000754p:plain:w300
RGB
f:id:hikita12312:20170827001618p:plain:w300
Luminance

異なる露光の合成

いわゆるHDR写真みたいな,異なる露光でトーンマッピングを行って 白飛びした領域のみ,低い露光値を適用するトーンマッピングを試しにやってみたいと思います。

まずは,ACESをつかって,露光の高い白飛びしたシーンを用意。

f:id:hikita12312:20170827002216p:plain:w300

このシーンの0.8以上の白飛びしかけのピクセルをピンク色で図示するとこんな感じ。

f:id:hikita12312:20170827002317p:plain:w300

で、白飛びしかけのシーンを露光1/4倍で暗くマッピングしたものと補間しながら合成。

const float3 mappedColor1 = Tonemap(TONEMAP_FUNC_TYPE, outputColor.rgb, EXPOSURE, WHITE_LUMINANCE);
const float3 mappedColor2 = Tonemap(TONEMAP_FUNC_TYPE, outputColor.rgb, EXPOSURE / 4.0f, WHITE_LUMINANCE);
#ifdef LUMBASE
const float lum = GetLuminance(mappedColor1);
#else
const float lum = max(mappedColor1.r, max(mappedColor1.g, mappedColor1.b));
#endif
const float x = smoothstep(0.8f, 1.0f, lum);
outputColor.rgb = lerp(mappedColor1.rgb, mappedColor2.rgb, x);

f:id:hikita12312:20170827002434p:plain

左が合成前,右が合成後

f:id:hikita12312:20170827002216p:plain f:id:hikita12312:20170827002434p:plain

確かに白飛びしているウサギの顔や,空の青色がはっきり見える。
ただ,不自然に明暗が変わっていて微妙な気もします。調整の余地あり。

ブルームの実装

ブルーム

前回,HDRテクスチャのトーンマッピングをしたので, よりHDRテクスチャを使っている感を出すためにブルームを実装します。

ブルームとは光が周辺のピクセルに滲み出るようなエフェクトの事を呼びます。詳しくはUnityの説明にでも。
https://docs.unity3d.com/jp/540/Manual/script-Bloom.html
現実のカメラ的には,ブルームはレンズ内部の光の乱反射とかが原因で起こるものだそうです。

ブルームの実装方法の概略は以下の通りです。

  1. ソースとなるHDRテクスチャからブルーム効果の発生する高輝度成分ピクセルを抜き出したHDRテクスチャを作る
  2. 高輝度テクスチャをブルームの種として,ガウスフィルターをかける
  3. ガウスフィルターによってボケた高輝度テクスチャを元のHDRテクスチャに書き戻す

簡単そうですね。

高輝度成分の抜き出し

こんな感じのシェーダーを書きました。定数バッファとかの定義は割愛。

float4 PS_HighLuminanceFilter(PPFX_OUT In) : SV_TARGET0
{
    float3 src = g_SrcTexture.SampleLevel(g_LinearSampler, In.UV.xy, 0.0f).rgb;
    float luminance = GetLuminance(src.rgb);
    return float4(src.rgb * max(0.0f, (luminance - HIGH_LUM_THRESHOLD)) * EXPOSURE, 0.0f);
}

注目するべきは,トーンマッピング時の露光(Exposure)をマスク後に乗算しているところ。
露光によってブルームエフェクトの滲み出しのサイズは変わるべきなので,ブラーを掛ける前に露光乗算する必要があります。

高輝度成分抜き出し時に輝度とソースカラーを乗算するような形になっていますが,輝度にブルームの量を比例させたかっただけです。
単純に高輝度以外は0でマスクするとか,いろいろ方法はあると思います。

で,抜き出された高輝度テクスチャが以下のものです。 f:id:hikita12312:20170820164458p:plain

高輝度抜き出しの閾値(HIGH_LUM_THRESHOLD)は0にしています。
左がソースとなるHDRテクスチャ,右がブラーの種となる高輝度成分テクスチャです。

f:id:hikita12312:20170813031754p:plain:w300 f:id:hikita12312:20170820164458p:plain:w300

現実世界のレンズ内部の光の乱反射には,ステップ関数的な閾値は無い気がしたので,閾値は結局0にしました。

ガウスブラー

いわゆるガウス関数を使って,注目するピクセルの周囲のピクセルを畳み込みます。

{ \displaystyle
f(x,y) = \frac{1}{Z}\exp{\Bigl(-\frac{x^2+y^2}{2\sigma^2}\Bigr)} 
}

{Z}は正規化因子です。畳み込むカーネルサイズの重みを全部足して1になるように規格化します。
ちなみにガウス関数x,yで分離して別々に積分できるので,X軸方向への1次元ガウスブラーをかけた後に, Y軸方向への1次元ガウスブラーをかければ,テクセルフェッチの数を減らしつつ2次元のガウスブラーと同じ結果を得られます。
昔に学校で証明をやった気がしますが忘れました。

f:id:hikita12312:20170820174025p:plain:w300

図はX軸方向に5x5のガウスブラーのカーネルをかけるときの重みの模式図です。
各テクセルでフェッチしてこの重みを掛けて足して,同じことをY軸方向で行えば5x5のガウスブラーが出来ますが, よくよく図を見ると,一番端の0.054の重みのテクセル部分が少し勿体無い気がします。 ガウス関数の端のほうはブラーを行う上での寄与が少ないのに1テクセルフェッチするのが少し無駄になっている気もします。

ガウス関数の変曲点までは普通に各テクセルを1テクセルずつフェッチして, 変曲点より先は,ガウス関数の端のほうとみなして,の微分値から1~2テクセルをリニアサンプラを利用してフェッチすることで, カーネルサイズを見かけ上稼ぐという方法を使ってみます。

f:id:hikita12312:20170820174802p:plain:w300

5x5のガウスカーネルが,7x7のガウスカーネルになりました。 ガウス関数の端のほうは重みが小さいので,適当に線形補間しても,あまり問題はないでしょう。 当然精度は落ちますが,そもそもの目的はガウスブラーを正確にかけることではなく,ブルームエフェクトの実装なので,良しとします。

縮小バッファによるブラーの拡大

ガウスブラーを利用する限り,大きなブラーをかけるときには,ガウスカーネルのサイズがどうしても大きくなってしまうという問題があります。 これを解決するために,昔からよく利用されている方法が縮小バッファ法です。

f:id:hikita12312:20170820180200p:plain

ガウスブラーのカーネルサイズは,そこそこのサイズ(今回は11x11)としてブラーをかけますが, 1/2倍に縮小したテクスチャを次々に作って,さらに同じサイズのカーネルでブラーをかけます。
そして,最終的に出来上がった1/2,¼,…サイズのブラーテクスチャ列を,適当に線形補間しつつ合成すれば大きなブラーのかかったブルームが出来るという方法です。

テクスチャを1/2に縮小することで,ガウス関数標準偏差が実質2倍になったような効果を与えることが出来て,さらに再帰的にブラーをかけるので, どんどん大きなブラーをかけることが出来ます。縮小をすればするほど,テクスチャサイズが小さくなっていくので,GPUへの負荷も大きくはないというのも利点です。
再帰的にブラーをかけることで,1/2サイズの縮小を繰り返すことによるブロック状のアーティファクトが発生を抑えるという効果もあります。

以下が,実際に縮小バッファ法によって作成された,各サイズのブラー結果です。

f:id:hikita12312:20170820181048p:plain

そもそもブラーという情報量を落とす処理なので,ブルームの種となる高輝度テクスチャも1/2サイズで作成して負荷を下げています。 この5枚のガウスブラー結果を単純に算術平均した結果が以下のブルームテクスチャです。

f:id:hikita12312:20170820181553p:plain

このブルームテクスチャを,元のHDRテクスチャに加算して,トーンマップ,露光調整,ガンマ補正等を行った最終結果が以下のものです。

f:id:hikita12312:20170820181720p:plain
f:id:hikita12312:20170813031817p:plain f:id:hikita12312:20170820181720p:plain

左はブルームOFF,右はブルームONです。ふわっと光が漏れ出して眩しい感じの表現ができているかと思います。
ちなみにブルーム関連処理のGPUでの実行時間は,1280x780の解像度の場合はGTX970のマシンで0.21ms程度でした。