Visual Studio向けに使うCMake

CMake

Visual Studioのバージョンを固定できず,複数のバージョンでビルドをしなければいけない場合があります。
slnファイルやprojファイルのXMLを直接編集したり,Visual StudioのUIからビルド設定を手作業で編集するのはとても面倒な作業ですが, そんなときに便利なのがCMakeです。

out-of-source Build

https://qiita.com/osamu0329/items/7de2b190df3cfb4ad0ca
CMakesを使うときは,out-of-source ビルドをすると,gitを使うときに便利です。

例えば,次のようなディレクトリを考えます。

MyProject/
 ├ include/
 ├ src/
 └ CmakeLists.txt

以下のように,単にcmakeを作業ディレクトリで動作させるだけだと,ソースファイルの隣にプロジェクトファイルや中間ファイルが生成されてしまいます。

C:\MyProject>cmake . -G "Visual Studio 15 2017 Win64"
MyProject/
 ├ include/
 ├ src/
 ├ CMakeFiles/
 ├ ALL_BUILD.vcxproj
 ├ ALL_BUILD.filters
 ├ ALL_BUILD.user
 ├ CMakeCache.txt
 ├ MyProjet.sln
 └ CmakeLists.txt

gitを利用する場合にファイルステータスに中間ファイルやらビルド生成物が列挙されて見通しが悪くなります。 また,誤ってソリューションファイルの設定を弄ってしまって,他の人の環境と生成物が異なってしまう,みたいなことも起きそうです。

そこで,次のコマンドのようにout-of-sourceビルドをすると便利です。

C:\MyProject>mkdir build
C:\MyProject>cd build
C:\MyProject\build>cmake ../ -G "Visual Studio 15 2017 Win64"
MyProject/
 ├ build/
 │ ├ CMakeFiles/
 │ ├ ALL_BUILD.vcxproj
 │ ├ ALL_BUILD.filters
 │ ├ ALL_BUILD.user
 │ ├ CMakeCache.txt
 │ └ MyProjet.sln
 ├ include/
 ├ src/
 └ CmakeLists.txt

ビルド中間ファイルがbuildディレクトリ以下にまとまりました。 あとは,gitignoreでbuildディレクトリ以下を無視すれば,ソースディレクトリやインクルードディレクトリを綺麗なまま使えます。
buildディレクトリ以下はCMakeLists.txtの設定によって自動生成されるので,環境によりません。 多人数で開発していて他の人がリポジトリをクローンしたら動かなかった、 みたいなこともbuildディレクトリを一旦削除してcmakeしなおすだけで再現できるので,皆がビルドできる環境を再現しやすいです。

フィルタの利用

http://blog.techlab-xe.net/archives/5340

Visual Studioには,フィルタ機能という機能があります。 これは,実際のディレクトリ構造と別に,Visual Studio内部で利用されるツリー構造を保持する機能です。 CMake上でこれを有効化するには,次のコマンドをCMakeLists.txtに記述します。

set_property(GLOBAL PROPERTY USE_FOLDERS ON)
source_group(${FilterName} FILES ${SrcFiles})

ただ,大抵の場合は実際のディレクトリ構造とフィルタ構造は一致させたい場合が殆どだと思います。
その場合は,TREE指定をすることで再帰的にフィルタを作成してくれます。

set_property(GLOBAL PROPERTY USE_FOLDERS ON)

set(PrefixDir "\\src")
source_group(TREE ${SrcDir} PREFIX ${PrefixDir} FILES ${SrcFiles})

これで,SrcDir以下に存在するSrcFilesの構造をフィルタに登録します。PrefixDirはフィルタの先頭に付与される親フィルタで, 上の例では"\\src"を指定しています。このPrefixDirはVisual Studioの場合は"\\"で先頭をエスケープした区切り文字でルートから記述しないとうまく動かないようでした。

プリコンパイル済みヘッダ

プロジェクト全体で利用されるような共通のヘッダ定義を事前にコンパイルすることで,ビルド全体の高速化を図るのがプリコンパイル済ヘッダです。 Visual StudioでもGUI上でプリコンパイル済ヘッダを作成・指定ができるのですが,これをCMakeから自動で行うこともできます。

https://stackoverflow.com/questions/148570/using-pre-compiled-headers-with-cmake

set_source_files_propertiesを利用して,直接プリコンパイル済ヘッダ作成・利用のためのオプションを指定しています。 手作業でファイルを追加するときには,プリコンパイル済みヘッダの指定を忘れてしまうこともありますが,CMakesで自動で行えると便利です。

doxygenとの連携

doxygenソースコードからドキュメントを生成するツールですが,これをCMakesと連携させておくと, デプロイ時や普段のちょっとした開発時に手早くドキュメントを作成できて便利です。

project(doc)
# ${CMAKE_CURRENT_BINARY_DIR}から${include_dir}への相対パス
file(RELATIVE_PATH relative_include_dir ${CMAKE_CURRENT_BINARY_DIR} ${include_dir})
# doxygenの場所
set(DOXYGEN_PATH "C:\\Program Files\\doxygen\\bin\\doxygen.exe")
# doc.config内部で利用する変数のセット
set(DOXYGEN_INPUT "${relative_include_dir}")
set(DOXYGEN_OUTPUT_DIRECTORY "${CMAKE_CURRENT_BINARY_DIR}")
set(DOXYGEN_ENABLED_SECTIONS "LANG_JP")
# テンプレートから,doc.configを生成
set(DOXYGEN_CONFIG_FILE "${CMAKE_CURRENT_BINARY_DIR}/doc.config")
configure_file (
        ${CMAKE_CURRENT_SOURCE_DIR}/../template/doc.config
        ${DOXYGEN_CONFIG_FILE}
    )
# doxygen起動コマンドの指定
add_custom_target(
    ${PROJECT_NAME} ALL
    SOURCES ${include_files}
    COMMAND cmd /c $<SHELL_PATH:${DOXYGEN_PATH}> $<SHELL_PATH:${DOXYGEN_CONFIG_FILE}>
)

ポイントはdoxygenのコンフィグファイルをconfigure_file()で作成する点です。 このテンプレート内部ではCMakeの変数参照ができるので,これを利用して作成するビルドプランごとに異なるドキュメントを作成できます。 マルチプラットフォームなプログラムを書く場合や,各言語のドキュメントを用意するときに便利だと思いました。

Temporal Anti-Aliasingの実装

Temporal Anti-Aliasing

Temporal Anti-Aliasing(TAA)とは,アンチエイリアスの一種です。
前に組み込んだ,FXAAとかSMAAは1フレームの情報から, スクリーンスペースで画像処理的に行うアンチエイリアスでした。TAAは過去のフレームも使ってサンプリング数を稼いで行うアンチエイリアスです。

http://advances.realtimerendering.com/s2014/index.html#_HIGH-QUALITY_TEMPORAL_SUPERSAMPLING
https://github.com/Unity-Technologies/PostProcessing/blob/v2/PostProcessing/Shaders/Builtins/TemporalAntialiasing.shader

過去のフレームほど,重みを小さくして,1ピクセル未満の範囲でサンプリング位置を変えて,それらを合成するのが基本的なアイディアです。

f:id:hikita12312:20180722095207p:plain:w600

ただし,過去のフレームすべてを保持しておくことは行いません。 指数的に減少するような重みを採用して,指数移動平均とすることで,過去のフレームの積分である履歴バッファのみを保持します。
https://ja.wikipedia.org/wiki/%E7%A7%BB%E5%8B%95%E5%B9%B3%E5%9D%87#%E6%8C%87%E6%95%B0%E7%A7%BB%E5%8B%95%E5%B9%B3%E5%9D%87

{ \displaystyle
\begin{eqnarray}
[\mathrm{Output}] &=& \alpha [\mathrm{Input}] + (1-\alpha) [\mathrm{History}] \\ 
[\mathrm{History}] &\leftarrow& [\mathrm{Output}]
\end{eqnarray}
}

Jitter

カメラの透視投影行列を上下左右に1ピクセル未満のオフセットを加えます。

{ \displaystyle
\begin{eqnarray}
a &=& \mathrm{aspect} \\
\theta &=& \mathrm{Fov Y} \\
f &=&  \mathrm{far Z}\\
n &=&  \mathrm{near Z}
\end{eqnarray}
}
{ \displaystyle
\begin{eqnarray}
\left(  
\begin{array}{cccc}
\frac{1}{a \tan\theta } & 0 & -2\mathrm{Offset_x} & 0 \\
0 & \frac{1}{\tan\theta } & -2\mathrm{Offset_y} & 0 \\
0 & 0 & \frac{f}{f-n} & -\frac{f n}{f-n} \\
0 & 0 & 1 & 0
\end{array}
\right)
\end{eqnarray}
}

DX系でのオフセットの掛け方の例です。透視投影行列の計算のnear平面の四角形をずらせば導出できます。
この微妙なオフセットがジッターとして各フレームで時間的な遅れを持ちながらもマルチサンプリング的な効果を与えます。

ずらすオフセットの量ですが,Halton列を利用しました。
https://en.wikipedia.org/wiki/Halton_sequence

VelocityMap

履歴バッファを単にサンプリングしているだけだと,以前実装した残像表現の効果と同様にカメラやオブジェクトが動くと残像として尾を引くアーティファクトが生じてしまいます。 これを補正するために速度バッファを利用して,1フレーム前に参照されていたピクセルと同じ位置の履歴バッファを参照します。

ただし,速度が大きくなってしまうと,やはり誤差が大きくなって残像が出てきてしまうので, 速度ベクトルの長さを利用して動きの速いピクセルほど指数移動平均の重みを小さくすることで残像を消していきます。

また,単に速度ベクトルを使わないで,近傍のテクセルの中から,depthを参照して最も手前にある速度ベクトルを採用すると,うまくいくようです。

Tonemap

入力されたピクセルがあまりに高輝度だと,高輝度成分の緩和に時間がかかって, フリッカーとしてチラついたり,まったくアンチエイリアスがかからなくなってしまったりと不都合が生じます。 理論上は無限時間かけて沢山サンプリングすれば軽減されるのですが, 高輝度成分をサチらせるようなトーンマップをかけてSDR空間上でTAAを行うことでも軽減できます。

{ \displaystyle
\begin{eqnarray}
k &=& \frac{a}{(1-a)L_{\mathrm{mean}}}
\end{eqnarray}
}
float3 TonemapTAA(float3 color)
{
    color *= k;
    return color / (1.0f + max(color.r,max(color.g, color.b)) );
}

float3 InvTonemapTAA(float3 color)
{
    color = color / (1.0f - max(color.r, max(color.g, color.b)) );
    return color / k;
}

このトーンマップ関数は,輝度に対するReinhard関数となっています。 逆トーンマッピング時に発散しないように,輝度は内積を使うものではなく,maxを取るものとしました。
kは露光に対応する係数ですが,平均輝度L_{\mathrm{mean}}がトーンマップによってKeyValueであるa=0.18に移るように選んでいます。

Colorのクランプ,輝度差のある履歴の不採用

TAAはアンチエイリアスなので,原理的には入力となるピクセルの周辺3x3範囲からテクスチャをサンプリングされるはずなので, 少なくとも3x3範囲の色には無い高輝度な色や暗すぎる色はサンプルされないはずです。 周辺の色のmin,maxを取ってクランプすることで,残像を除去します。

また,履歴バッファと入力カラーの輝度差があまりにも大きいと,フリッカーの原因となるので,これを除去するような係数も入れます。

以上の処理を適用したシェーダーが次のものです。

結果

f:id:hikita12312:20180722192934p:plain:w300
TAA OFF
f:id:hikita12312:20180722192956p:plain:w300
TAA ON
f:id:hikita12312:20180722193041p:plain:w300
TAA OFF
f:id:hikita12312:20180722193058p:plain:w300
TAA ON

なんだかアンチエイリアスが掛かっている気がします。 画面を動かしても不自然なゴーストはできません。 しかし,動画としてみると,高周波な部分がジッターで若干画面がザラつくような効果があるので,もっと調整が必要かもしれません。

RICHO THETAでHDRIの作成

RICHO THETA

360度カメラである,THETA SCを買いました。
解像度は静止画で5376x2688で,だいたい4K相当です。 マルチブラケット撮影もできるので,Photomatix ProみたいなHDRIの合成ソフトを使ってみたら,すぐHDRIができました。

http://cgcompo.blog134.fc2.com/blog-entry-67.html
https://www.hdrsoft.com/jp/

ただ,便利なソフトを使ってみただけでは面白くないので自分でHDRIの合成をしてみたいと思います。

HDRIの作成

まずは,以下のように複数の露光でマルチブラケット撮影をしておきます。

f:id:hikita12312:20180612103843p:plain:w600

THETAは絞りが弄れない関係上,どうしても明るく撮れてしまうので,木陰など暗めの場所で撮ったほうがよいと思いました。 また,撮影時間も30~40秒ぐらいはかかるので,三脚とシータ棒は必須です。

次に,各画像を取り込んでシェーダーでRGBA16Fフォーマットのテクスチャへ合成します。 THETAは残念ながらRAW画像は取得できないので,JPEGとして読み込みます。 画像を取り込むときには,EXIF情報も同時に読んで,露光時間とF値,ISO感度からEV値を計算しておきます。

{ \displaystyle
\begin{eqnarray}
\mathrm{EV} = \log_2{\Big( \{\mathrm{Fnumber}\}^2 * \{\mathrm{ExposureTime}\} * \frac{100}{\{\mathrm{ISO}\}} \Big)}
\end{eqnarray}
}

画像の読み込みはDirectXTexを利用しました。EXIF情報はIWICMetadataQueryReaderから読んでいます

https://github.com/Microsoft/DirectXTex
https://msdn.microsoft.com/ja-jp/library/windows/desktop/ee719904(v=vs.85).aspx

入力画像を1枚のHDRIとして合成するときには,EV値が高い画像ほど,値としては暗い部分になるようにスケールします。 いろいろ合成のやり方はあると思いますが,今回は三角形型の重み関数で合成を行っています。

f:id:hikita12312:20180612104234p:plain:w600

合成を行う時に,ソースとなる画像のあまりに暗すぎる部分や明るすぎる部分を計算から省くような処理を行っています。 matlabのドキュメントでも似たようなことをしているようです。

https://jp.mathworks.com/help/images/ref/makehdr.html

実際の合成を行うシェーダーは以下。

結果

出来上がったHDRIを使って,レンダリングをしてみました。

f:id:hikita12312:20180612105925p:plain:w600
f:id:hikita12312:20180612105925p:plain:w300 f:id:hikita12312:20180612105946p:plain:w300

中央の球体のレンダリングはUnrealEngineのPBRを実装しました。

http://blog.selfshadow.com/publications/s2013-shading-course/

ちゃんと高輝度成分が入っているので,ブルームエフェクトも機能しています。 HDRI合成の重みを工夫したらもっとキレイにできそうです。

モーションブラーの実装 その2

前にも,モーションブラーの実装を行いました。
このときは「A Reconstruction Filter for Plausible Motion Blur」という論文をそのまま実装したのですが, 余りにもそのまま実装しただけだったので,もう少しモーションブラーを考えてみます。

カメラモーションブラー

前回は,シーンレンダリング時に1フレーム前のオブジェクトのModelViewProjection行列を保存しておいて, 現在のフレームのModelViewProjection行列の結果との差分を取る方法で速度マップを作成していました。
この方法で間違いは無いのですが,今回は現在のシーンのDepth値とカメラのViewProjection行列のみを利用する方法を試してみます。

https://developer.nvidia.com/gpugems/GPUGems3/gpugems3_ch27.html

const float2 uv = In.aUV[0].xy;
const float depth = SampleTextureLevel0(g_DepthTexture, g_LinearSampler, uv).r;

// ScreenSpace空間 -1 ~ 1に変換
const float2 screenPos = uv * 2.0f - 1.0f;
// depth値から1フレーム前のスクリーン位置を計算
float4 prevPos = float4(screenPos, depth, 1.0f);
prevPos = Transform(CURRENT_INV_VIEWPROJECTION, prevPos);
prevPos /= prevPos.w;
prevPos = Transform(PREV_VIEWPROJECTION, prevPos);
const float2 prevScreenPos = prevPos.xy / prevPos.w;
// 現在のスクリーン位置
const float2 currentScreenPos = screenPos;
// -1~1のスクリーンスペースでの速度ベクトル
// cameraVelocityはUV空間での速度ベクトルなので,0.5倍
float2 cameraVelocity = (currentScreenPos - prevScreenPos) * 0.5f;

シェーダーにはDepthバッファと,CURRENT_INV_VIEWPROJECTIONで表される現在のViewProjection行列の逆行列, PREV_VIEWPROJECTIONで表される1フレーム前のViewProjection行列を渡しています。
スクリーン空間からDepth値を使ってWorld空間座標値を復元し,そこから1フレーム前のViewProjectionを適用して1フレーム前のスクリーン空間座標を求めています。

この方法の利点としては,シーンのレンダリングに関係なく速度マップを計算できることです。ポストエフェクト的に完全に後処理だけで速度マップを計算できます。 また,シーン自体の解像度よりも速度マップを縮小することもできるので高速化も可能です。
欠点としては,シーン内部のオブジェクト自体の速度を判別することが出来ないことです。カメラが固定でオブジェクトが高速に動くようなシーンのモーションブラーには不向きです。 ただ,オブジェクトがそんなに動かないゲームなら気にならない気もします。

ピンポンブラー

モーションブラーの計算時に,光芒で利用したような,ピンポンブラーを使ってみました。
https://game.watch.impress.co.jp/docs/20080310/3dcry.htm

f:id:hikita12312:20180505163952p:plain:w600

前回の「A Reconstruction Filter for Plausible Motion Blur」の実装や, UnityのPostProcessingStackのモーションブラーの実装だと,長さ64ピクセルのブラーを行う場合には,64回のテクスチャフェッチが必要でした。 しかし,再帰的にブラーを繰り返すことで,8回のサンプリング2回,合計16回のテクスチャフェッチで,同等のブラーが実現できます。

以下がピンポンブラーのシェーダーの一部抜粋です。

// このシェーダーを数回繰り返す
const float2 uv0 = In.aUV[0].xy;
const float3 c0 = SampleTextureLevel0(g_ColorTexture, g_LinearSampler, uv0).rgb;
// g_VelocityDepthTextureは,RGに速度マップ,BにDepth値が格納されている
const float4 vd0 = SampleTextureLevel0(g_VelocityDepthTexture, g_LinearSampler, uv0);
const float2 v0 = vd0.xy;
const float d0 = vd0.z;

// ブラー処理
const float jitter = (rand(uv0) - 0.5f) * 0.5f; // jitter -0.25f ~ 0.25f
float4 color = float4(c0, 1.0f); // 0番目のcolorは必ずサンプル
[unroll]
for (uint i = 1; i < 4; ++i)
{
    // BLUR_DELTA = powf(4.0f, passIteration) / blurMaxSampleNum;
    const float2 delta = v0 * (BLUR_DELTA * (i + jitter) );
    for (uint j = 0; j < 2; ++j)
    {
        const float2 offset = ((j == 0) ? (1.0f) : (-1.0f)) * delta;
        const float2 uv = uv0 + offset;
        const float3 c = SampleTextureLevel0(g_ColorTexture, g_LinearSampler, uv).rgb;
        const float d = SampleTextureLevel0(g_VelocityDepthTexture, g_LinearSampler, uv).z;
        // 手前にあるピクセルをサンプルしない為の重み
        const float w = step(d0, d + DEPTH_BIAS);
        color += float4(c, 1.0f) * w;
    }
}
color.rgb /= color.a;

Depth値とVelocity値を同じバッファに格納しています。また,変なゴーストが出ないように弱くjitterを掛けます。 また,モーションブラーは動いている物体より止まっている手前の物体の色はサンプルされないはずなので,マスクを掛けています。

結果

カメラモーションブラーとピンポンブラーを実装した結果です。 画面奥方向にカメラが動いている場合のモーションブラーのON/OFFを比較しています。

f:id:hikita12312:20180505165229p:plain:w600
f:id:hikita12312:20180505165200p:plain:w300
モーションブラーOFF
f:id:hikita12312:20180505165229p:plain:w300
モーションブラーON

まあまあ綺麗にそれっぽくなっています。Depthから速度マップを作成しているので,画面の奥の方の地面ほど,ちゃんとブラーの長さが小さくなっています。
ただし,結果を見ると,かなりエッジが汚いです。エッジ処理を何もしていないからなのですが,この辺が改善の余地ありです。Reconstruction Filterの方法と組み合わせてみるとか...

トーンマップのLUT化

トーンマップパスの修正

以前,トーンマップカラーコレクションを実装しましたが, 思うところあって,パスを次のように修正しました。

f:id:hikita12312:20180426103024p:plain:w600

前は,パス内部で直接トーンマップ関数の計算をしたり,色行列を乗算していました。 それだと,組み合わせが膨大になり,用意するシェーダー数が膨大になることと,複雑な計算をする場合に遅くなる懸念があったので,思い切ってLUT化しています。

また,細かいところですが,アンチエイリアスをトーンマップ後のSDR空間にて適用することにしました。 ノイズやディザリングを付与する処理も,アンチエイリアス後に適用しています。これは、アンチエイリアスで細かいノイズが消されないようにするためです。

これらの実装は,UnityのPostProcessingStackと似た実装になっています。

LUTの作成

LUTは3DテクスチャのXYZの軸をRGBと読み替えてマッピングするテーブルのことですが,単純に作ってしまうと0~1の範囲の色のテーブルしか作れません。 今回は無限大の範囲まで持つHDRカラーから,0~1までのSDRカラーにマッピングする必要があります。 HDRカラーの全範囲はLUTとして記録できないので,適当な範囲を定めて圧縮することで対応します。

UnityのPostProcessingStackの場合は.Alexa LogC Curveという対数カーブでHDRカラーを圧縮しているようです。

今回は,Alexa LogCではなく,S-Log3を使って圧縮をしたいと思います。 S-Log3を選んだ理由は特にありません。強いて言えば日本語の文献が沢山あったからでしょうか。Alexa LogCでもS-Log3でも,結局はlogの積和計算で表されて係数が違うくらいなので,大きな違いは無いんじゃないかと思います。

LUTを作成するCompute Shaderは以下のようなものになりました。

LUTを作る段階で,カラーコレクションやガンマの調整を含んでいます。 HDRからSDRへのトーンマップを行うときは,入力カラーにlinear2log()関数を適用したRGB値でLUTを引くことで実現します。

結果

Reinhard関数のLUTを作成し,トーンマップをした結果です。 LUTのサイズは64x64x64です。128にしても結果に差が出なかったので,64にしています。

f:id:hikita12312:20180426105609p:plain:w600

ついでに,GT-Tonemapを適用してみました。

https://www.slideshare.net/nikuque/hdr-theory-and-practicce-jp
https://www.desmos.com/calculator/et1qmcg10s

f:id:hikita12312:20180426105751p:plain:w600

歪曲収差の実装

歪曲収差

レンズを通した光が,像となるときに歪んでしまう収差のことです。 大雑把に言えば魚眼レンズみたいな歪みです。

https://en.wikipedia.org/wiki/Distortion_(optics)
http://www.nikon-instruments.jp/jpn/learn-know/microscope-abc/learn-more-microscope/about-aberration

格子状のグリッドを撮影したときに,樽型に歪んだり(樽型),中央に収縮するように歪んだり(糸巻き型)します。

半径方向歪み

Brownのレンズ歪みモデルなるものがレンズ歪みを表すモデルとして使われることが多いようです。

https://www.asprs.org/wp-content/uploads/pers/1971journal/aug/1971_aug_855-866.pdf
http://www.roboken.iit.tsukuba.ac.jp/~ohya/pdf/Robomech2014-KNS.pdf

Brownのレンズ歪みモデルでは,レンズの光軸中心からの半径方向の歪みと,円周方向の歪みをわけて表現されますが, まずは半径方向歪みについて考えます。

光軸中心を{(c_x,c_y)}として,光軸中心からの像の位置を{{\bf \bar{r}}=(\bar{x},\bar{y}) = (x-c_x,y-y_c)}とします。 この時,歪んだ像の位置{{\bf r}_d = (x_d,y_d)}は,距離の2乗の多項式の展開で表せるとしたものが,多項式モデルと言うそうです。

{ \displaystyle
\begin{eqnarray}
{\bf r}_d = \Big(1 + k_1 r^2 + k_2 r^4 + k_3 r^6+ \cdots \Big) {\bf \bar{r}}
\end{eqnarray}
}

一方,OpenCV等の画像処理ライブラリでは,カメラキャリブレーションに,有理関数モデルを利用しているようです。

http://opencv.jp/opencv-2svn/cpp/calib3d_camera_calibration_and_3d_reconstruction.html
https://www.robots.ox.ac.uk/~vgg/publications/2005/Claus05a/claus05a.pdf

{ \displaystyle
\begin{eqnarray}
{\bf r}_d = \frac{1 + k_1 r^2 + k_2 r^4 + k_3 r^6+ \cdots}{1 + k_4 r^2 + k_5 r^4 + k_6 r^6+ \cdots} {\bf \bar{r}}
\end{eqnarray}
}

他にも半径方向歪みのモデルには,有理関数モデルの特殊化として{k_4}以外0としたDivisionModel )http://www.robots.ox.ac.uk/~vgg/publications/papers/fitzgibbon01b.pdf) とか, 魚眼モデルとかFOVモデルといった多項式で表せないものもあります。
ちなみに,UnityのPostProcessingStackの歪曲収差は,樽型のときにFOVモデルを利用しているようなコードでした。

https://pdfs.semanticscholar.org/260d/bcca2edddeb7f743d186cc99a7d586023234.pdf
http://www.close-range.com/docs/Straight_Lines_Have_To_Be_Straight-Automatic_Calibration_and_Removal_of_Distortion--Devernay-Faugeras2001.pdf

今回はOpenCVのカメラキャリブレーションにも使われている実績や,計算の簡単さから4次までの有理関数モデルをレンズ歪みに利用することにします。

{ \displaystyle
\begin{eqnarray}
{\bf r}_d = \frac{1 + k_1 r^2 + k_2 r^4}{1 + k_3 r^2 + k_4 r^4} {\bf \bar{r}}
\end{eqnarray}
}

円周方向歪み

レンズによる光学的な歪みではなく,レンズとフィルムが完全に並行ではない場合に生じうる幾何学的な歪みが円周方向の歪みの項です。

{ \displaystyle
\begin{eqnarray}
\Delta x_t = \Big( 2p_1\bar{x} \bar{y} +p2(r^2 + 2\bar{x}^2)\Big) \Big(1+ p_3r^2 + \cdots\Big) \\
\Delta y_t = \Big( p1(r^2 + 2\bar{y}^2) + 2p_2\bar{x} \bar{y}\Big) \Big(1+ p_3r^2 + \cdots\Big) 
\end{eqnarray}
}

この項の{p_3r^2}以降を無視して,先程のレンズ歪みモデルに組み込みます。

{ \displaystyle
\begin{eqnarray}
{\bf r}_d = \frac{1 + k_1 r^2 + k_2 r^4}{1 + k_3 r^2 + k_4 r^4} {\bf \bar{r}} + 
\left[
\begin{array}{cc}
2p_1\bar{x} \bar{y} +p2(r^2 + 2\bar{x}^2) \\ 
p1(r^2 + 2\bar{y}^2) + 2p_2\bar{x} \bar{y} \\
\end{array}
\right]
\end{eqnarray}
}

結果

歪曲収差の式をUVに適用してテクスチャのフェッチする位置を歪めることで実装します。

半径方向歪みが正の値の場合(UVフェッチ時逆転なので,本来の計算式的には負),歪みが外に膨らんで行くので樽型の歪曲収差を作れます。

f:id:hikita12312:20180407140007p:plain:w300
歪曲収差 OFF
f:id:hikita12312:20180407140049p:plain:w300
歪曲収差 ON (樽型)

ただし,樽型の場合,画面内に元のテクスチャよりも外側のUVもフェッチされてしまうので,画面端の方が不正確なものになります。

f:id:hikita12312:20180407140123p:plain:w300 f:id:hikita12312:20180407140049p:plain:w300

そのため,歪曲収差を適用した後のUVを画面中心から拡大することで,見せたくない画面外側の範囲をクリップしても良いでしょう。

f:id:hikita12312:20180407140654p:plain:w300 f:id:hikita12312:20180407140631p:plain:w300

半径方向歪みが負の値の場合は,糸巻き型となります。

f:id:hikita12312:20180407140007p:plain:w300
歪曲収差 OFF
f:id:hikita12312:20180407140815p:plain:w300
歪曲収差 ON (糸巻き型)
f:id:hikita12312:20180407140854p:plain:w300 f:id:hikita12312:20180407140815p:plain:w300

樽型と糸巻き型の歪みが組み合わさったものは陣笠型と呼びますが, これもパラメータの組み合わせで半径方向の歪みが距離によって正負入れ変えるような関数系をつくって再現できます。

f:id:hikita12312:20180407141119p:plain:w300 f:id:hikita12312:20180407141052p:plain:w300

円周方向の歪みのパラメータを与えると,画面自体が奥に傾いたような歪みを作り出すこともできます。

f:id:hikita12312:20180407141454p:plain:w300 f:id:hikita12312:20180407141433p:plain:w300

実装してみると,思っていたよりも画面端の違和感が強いので, 周辺減光や, 倍率色収差をかけると, よりレンズっぽく画面端をごまかせるかと思います。

f:id:hikita12312:20180407141747p:plain:w600

SMAAの組み込み

SMAA

スクリーンスペースのアンチエイリアスとして,FXAAを組み込みました。 今回は,もう少し綺麗になるらしい,スクリーンスペースのアンチエイリアスであるSMAAを組み込んでみます。

http://www.iryoku.com/smaa/
https://github.com/iryoku/smaa

UnityのPostProcessingStack v2にも組み込まれているようです。

SMAAの組み込み

f:id:hikita12312:20180324122235p:plain:w600

SMAAは3つのパス(エッジ検出パス,ブレンド重み決定パス,ブレンドパス)と,テーブルとして利用されるテクスチャ2つ(AreaTexture,SerchTexture)が必要となります。 上記githubから,次のファイルを持ってきます。

  • smaa/SMAA.hlsl
  • smaa/Textures/SearchTex.h
  • smaa/Textures/AreaTex.h

SMAA.hlslはシェーダー本体で,SearchTex.h,AreaTex.hはテーブルとして利用されるテクスチャのバイナリ定義です。 SearchTextureはRG8フォーマット,AreaTextureはR8フォーマットとして,先に読み込んでおきます。

SMAA.hlslをインクルードして,3つのパスを定義しますが,この時,次のマクロを定義します。

  • SMAA_RT_METRICS :
    zwにスクリーンサイズ,xyにスクリーンサイズの逆数の入ったfloat4
  • SMAA_THRESHOLD :
    エッジ検出の閾値
  • SMAA_MAX_SEARCH_STEPS :
    ブレンド重み計算時のstep数
  • SMAA_MAX_SEARCH_STEPS_DIAG :
    ブレンド重み計算時のstep数。SMAA_DISABLE_DIAG_DETECTION定義で無効
  • SMAA_CORNER_ROUNDING :
    ブレンド重み計算時の角の丸め率? SMAA_DISABLE_CORNER_DETECTION定義で無効

実際の組み込み例は次のような感じです。

エッジ検出パスでは,輝度ベースでエッジ検出を行うSMAALumaEdgeDetectionPSパスか,Color値のそれぞれの要素の差分から検出を行うSMAAColorEdgeDetectionPSパスを選択できます。 また,Depthテクスチャを渡してエッジの誤検出を防ぐような仕組みもあるようです。 今回は利用していませんが,TAA的に履歴バッファを利用してさらにサンプル数を稼ぐようなパスも用意されています。

結果

SMAA.hlslのクォリティ定義SMAA_PRESET_HIGHにあたる設定で適用してみました。

f:id:hikita12312:20180324122424p:plain:w600
f:id:hikita12312:20180324122508p:plain:w300
SMAA OFF
f:id:hikita12312:20180324122526p:plain:w300
SMAA ON
f:id:hikita12312:20180324122633p:plain:w600

以前実装したFXAAは,エッジ部分がボケた形になってしあう弱点があるのですが, SMAAだとそのような事はおきず,綺麗にエッジのギザギザが無くなっています。 FXAAと比べてもかなり高品質な印象です。

ただし,FXAAだと0.14ms程度掛かっていた処理が,SMAAだと0.442ms程度まで増加しています。 やはり処理が複雑であることと,3つのパスを利用していることが効いているのでしょうか。