光芒の実装

光芒

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

光芒は物理的には,カメラの絞り羽根による光の回折現象によって特定の方向に光が伸びていくような現象のことを呼びます。
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程度でした。

HDRテクスチャからSDRテクスチャへのトーンマップ

HDRテクスチャ

普通のテクスチャ(=SDRテクスチャ)は,RGBAのそれぞれのチャンネルで0~255まで範囲で正規化して0.0~1.0の範囲の色を表現する8bit固定小数精度のものを指します。

一方,HDRテクスチャはそれぞれのチャンネルで浮動小数点精度の値を持つテクスチャの事を言います。 いわゆるfloat型の32bit浮動小数精度のフォーマットのHDRテクスチャもありますが,大抵ゲームでは16bit浮動小数を使うので, 今回は16bit浮動小数テクスチャをHDRテクスチャのフォーマットとして利用します。

当然浮動小数点型なので,値の範囲は0.0~1.0ではなく,16bit浮動小数ならば~65504までの範囲を表現することができます。 HDRレンダリングされたテクスチャをそのまんま画面に出力しようとすると,1.0以上の値はすべて同じ白色として表現されてしまいます。

f:id:hikita12312:20170813031754p:plain f:id:hikita12312:20170813031804p:plain

画像の左側はHDRテクスチャにレンダリングされた結果をそのまま表示しています。
画像の右側はピクセルの色がRGB各チャンネルの色が1.0以上となっていて,クランプされてしまった領域をピンク色でマスクしています。 せっかく高輝度成分の情報をテクスチャに積み込んだのに,これでは意味がありません。

トーンマッピング

表示される色は結局1.0でクランプされるので,高輝度な信号が1.0でサチるような関数でマッピングしてあげます。 今回はトーンマッピング関数として,Reinhard関数を使います。

{ \displaystyle
f(x) = \frac{x}{1 + x} 
}
f:id:hikita12312:20170813230017p:plain:w300

HDRテクスチャにトーンマッピングをした後が以下の画像です。 白飛びが軽減されているように見えます。 f:id:hikita12312:20170813031826p:plain

ただし,この画像ではガンマ補正をしていないので, この画像をディスプレイを通して目で見ると,陰となるような部分がかなり暗く見えるかもしれません。 とりあえずガンマ2.2として補正をしてみます。

{ \displaystyle
\gamma = 2.2 \\
f(x) = x^{1/\gamma} 
}

f:id:hikita12312:20170813031746p:plain

比較的まともにHDRのテクスチャを画面に出力できるようになりました。

露光

先程のトーンマッピングではReinhard関数{f(x) = \frac{x}{1 + x} }を利用しましたが, この関数{f(x)}の入力となるHDRカラーのスケールを考えてみます。
単純に入力を2倍にすれば2倍明るくなり,0.5倍にすれば半分の明るさになります。 これが写真などで言うところの露光に対応します。

この露光値(=トーンマップのX方向スケール)はどのように決めるべきなのでしょうか。 一般的にはシーン全体の輝度の平均値{\bar{L}}{k}へと変換するようなスケーリングを トーンマップへの入力とするように選ばれることが多いです。

{ \displaystyle
y = f(\alpha x) = f(\frac{k}{\bar{L}}x)
}

このときの{k}をkey value,もしくはmiddle grayなどと呼んだりします。 もちろん表現したいシーンによって他の基準で露光の値を変えるべきですが,一つの基準として今回はこちらを採用します。 ちなみに,key valueとして{k=0.18}が慣習的に使われやすい気がします(これも表現するシーンで変わる)。

画面の輝度の平均値の計算

画面の平均輝度をリアルタイムで計算する方法として,縮小バッファ法を利用します。

フラグメントシェーダにおいて,リニアサンプラを利用すれば4テクセル平均値を高速に1パスで計算することが可能です。

f:id:hikita12312:20170813031739p:plain:w150

はじめにフラグメントシェーダーで輝度を計算して,R16Fフォーマットのテクスチャに格納しておいて, リニアサンプラを利用しながら再帰的に1/2サイズのテクセルにコピーを行っていきます。 最後の1x1テクスチャになったときのテクセルの値が平均輝度として計算されるというわけです。 f:id:hikita12312:20170813031742p:plain

平均輝度の計算は必ず毎フレーム行うべきものでもなく, 自動露光処理のような露光値を徐々に目標値に近づけるような処理を行う場合ならば, 多少平均輝度の値がシーンから遅れがでても問題がないケースが多いです。 そのため,平均輝度を計算するときの縮小バッファのコピーも,1フレームに1回に制限して, 数フレームかけて平均輝度を計算するようにすると,フレームあたりの計算量が減って更に高速化できます。

また,実際に入力として与えられるHDRテクスチャは一つのシーン内部での各テクセルでの輝度に大きく差があります。 現実の太陽は100000ルクスぐらいの明るさで,蛍光灯で照らされた室内だと500ルクスくらいとか聞いたことがあります。 ゲームのようなCGでも各テクセルで100~1000倍近い輝度の差を持ったテクセルが混在していると考えると, 平均値を計算するときの加算時の誤差も無視できなくなってきます。
そこで,輝度を各テクセルで計算した後に,対数空間にマッピングしてあげて,対数平均を取るようにすることで,誤差を多少軽減できます。

{ \displaystyle
L_{\mathrm{log}} = \log{(L + \epsilon )}
}

{\epsilon}は発散封じの適当な値です。{\epsilon=0.5}ぐらいにしてます。 あとは最後の1x1テクスチャの対数平均輝度値を取得したら,expの逆関数をかけたら精度が高い平均輝度を求められます。

トーンマッピング結果

以上の方法で平均輝度を求めて露光値を定めてから,トーンマッピングとガンマ補正をした結果が以下です。 f:id:hikita12312:20170813031817p:plain この時の露光値は0.4程度の値でした。{y = f(\alpha x) = f(0.4 x)} 平均輝度は0.45程度でしょうか。

f:id:hikita12312:20170813031754p:plain f:id:hikita12312:20170813031817p:plain

左が生のHDRテクスチャで,右がトーンマップ+露光調整+ガンマ補正です。