ImGuiの導入

ImGuiとは

ImGuiとは,楽に組み込みができて,簡易的なGUIを提供するライブラリです。

https://github.com/ocornut/imgui

例えば,このようなコードを書くと,

ImGui::Begin("Stats", nullptr, window_flags);
ImGui::LabelText("", "FPS:%4.2f (%4.2f ms)", s_Fps);
ImGui::LabelText("", "Camera Pos:(%.2f, %.2f, %.2f )", cameraPosition.x, cameraPosition.y, cameraPosition.z);
ImGui::Spacing();
ImGui::SetNextTreeNodeOpen(true, ImGuiSetCond_Once);
if (ImGui::CollapsingHeader("PostEffect"))
{
    if (ImGui::TreeNode("Tonemap"))
    {
        ImGui::SliderFloat("Gamma", &pfxDesc.Tonemap.Gamma, 0.0f, 4.0f);
        // ...
        ImGui::TreePop();
    }
    // ...
    ImGui::TreePop();
}
//...
ImGui::End();

ImGui::Render();

こんな感じでGUIが作れます。

f:id:hikita12312:20171015113931p:plain:w600

手続き的にImGuiのnamespaceのAPIをコールするだけで,それっぽいGUIが出来上がるのでとても便利です。

ImGuiの組み込み方法

ソースコードを持ってくる

ImGuiのリポジトリからソースを持ってきて,自分のプロジェクトに必要なソースとヘッダを組み込みます。 必要なファイルは

  • imgui.h
  • imgui.cpp
  • imgui_draw.cpp
  • imgui_internal.h
  • stb_rect_pack.h
  • stb_textedit.h
  • stb_truetype.h

です。自分のライブラリに組み込むような場合は,imgui.hのみ公開ヘッダとすれば大丈夫かと思います。

プラットフォームに合わせた実装をする

(簡単) examplesを利用する方法

ImGuiを利用するためには,描画部分とマウスやキーボードの入力部分を実装しなければいけません。
その実装例がImGuiのリポジトリのexamplesディレクトリ以下に格納されているので,面倒ならばこのサンプルをそのまま使っても良いと思います。

例としてDX11版の実装を見てみます。 imgui_impl_dx11.hを見ると,

IMGUI_API bool        ImGui_ImplDX11_Init(void* hwnd, ID3D11Device* device, ID3D11DeviceContext* device_context);
IMGUI_API void        ImGui_ImplDX11_Shutdown();
IMGUI_API void        ImGui_ImplDX11_NewFrame();
IMGUI_API void        ImGui_ImplDX11_InvalidateDeviceObjects();
IMGUI_API bool        ImGui_ImplDX11_CreateDeviceObjects();

というAPIが定義されていて,Init()で初期化を行い,デバイスの作成・破棄が行われたらInvalidateDeviceObjects(),CreateDeviceObjects()をコールします。 ImGui名前空間APIをコールする前には,NewFrame()を呼ぶ必要があります。また,WindowsAPIのイベントを取得するために,

IMGUI_API LRESULT   ImGui_ImplDX11_WndProcHandler(HWND hWnd, UINT msg, WPARAM wParam, LPARAM lParam);

ImGui_ImplDX11_WndProcHandler()をグローバルな名前空間にexternで定義しておいて,組み込み先のアプリケーションのWNDPROCのコールバック内部でコールするようにします。

これだけで,とりあえずは自分のプロジェクトでImGuiを動作させるところまでは持っていくことができます。 DX11以外のプラットフォームでも同じような感じで組み込めるかと思います。

真面目に組み込む方法

  • examplesに自分の使いたいプラットフォームが無い
  • 独自に抽象化されたフレームワークを利用している,もしくは作成している

という場合には,自分で真面目にImGui::GetIO()を利用してImGuiの描画部分と入力部分を実装する必要があります。 これもexampleのサンプルコードの中身を参考にすれば良いです。 ImGuiIO::KeyMapやImGuiIO::MousePosなどに入力値を格納しておけばImGui内部で利用する入力として利用してくれます。

描画部分は,ImGuiIO::RenderDrawListsFnにImGui内部で利用するImDrawData構造体のを引数に,描画処理を行うコールバックを実装する必要があります。 ImDrawDataには描画に利用する頂点データや行列変換の定数バッファが格納されていて,これを利用して描画を行うことになります。 そのために,ImGui利用前にプラットフォーム固有のシェーダーや,Font用のテクスチャを作成する必要があります。

ひっかかり
シェーダーリフレクションを利用してシェーダーコードからInputLayoutを作っていたので, ImGuiの組み込みをしたときに,渡される頂点バッファのColor部分のデータが8bitUNORMのフォーマットだったことに少し躓きました。 DX11の場合はリフレクションで8bitフォーマットかどうかはわからないので,手動でInputLayoutを作成する必要があります。

スキャッターベースのボケ

ギャザーベースとスキャッターベース

前回までの被写界深度処理は,ギャザーベースと呼ばれているカテゴリの方法です。 周囲のサンプリングして集めて一つのピクセルの色を決定するという意味でギャザーベースと言われているのだと思います。
ただし,ギャザーベースでは"注目するピクセル"に対する処理なので, 前ボケのブラーを作るときにも問題になったように, 前ボケが焦点の合っている場所の上に重なるように生じる現象はできません。

https://gpuopen.com/gdc2017-cinematic-depth-of-field/

また,大きなサイズのボケを作ると,大きなカーネルが必要になったり,ボケの細かい形状の制御が出来なかったりする問題があります。

そこで,今回はスキャッターベースと呼ばれる,あるピクセルの情報を周囲のピクセルに書き込む方法でボケの処理を実装してみようと思います。

スキャッターベースの概略

以下のサイトを参考にしました。

https://www.slideshare.net/siliconstudio/cedec-2008imagire-day

https://www.slideshare.net/siliconstudio/cedec-2012-34760454

http://hexadrive.jp/lab/demo/726/

今回やってみた方法の基本的な流れはこんな感じです。

f:id:hikita12312:20171010102539p:plain:w600

画面全体のピクセルに対応する位置に縮退した矩形を作れるように頂点として入力します。 その4頂点をVertexShaderでボケサイズに応じて膨らませて,PixelShaderでボケの形状を決定します。 周囲のピクセルからサンプリングするわけではなく,ボケ自体が膨らむのがポイントです。
膨らませる処理にGeometryShaderを利用する方法もあると思いますが,今回は試していません。

マスク処理

画面の解像度が1280x720である場合,そのままピクセルとボケの矩形を対応させると, 一つの矩形あたり2個の三角形を書くとして約164万ポリゴンのレンダリングとなります。 こうなってしまうとリアルタイムで行うことが難しいので,例えば縮小バッファを利用して,1/2サイズのバッファを利用します。 バッファのサイズを半分にすれば4倍高速化できますが,それでも40万ポリゴン近くです。

そこで,今回は目立つボケ以外はスキャッター処理を行わないというマスクを作成しました。

f:id:hikita12312:20171010103823p:plain:w300
元画像
f:id:hikita12312:20171010103844p:plain:w300
マスク

マスクの条件は,

  • 輝度のエッジとなっている
  • 輝度が高い
  • ボケの直径(CoC)がある程度の大きさ以上

となっていて,輝度が高く周囲よりも明るいボケが目立つ領域であると仮定したマスクとなっています。 エッジの計算はSobelフィルターを利用しています。 マスクされる領域のスキャッターボケは,VertexShaderで頂点を膨らませないで縮退させておくと,ラスタライズの時点で無視されるので高速化出来ます。

ボケの形状の作成

VertexShaderで膨らんだスキャッターボケの矩形領域に対して,実際に五角形や六角形のボケを書いていきます。 今回書いたボケ形状用のPixelShaderのコード片は以下です。

float4 PS_Create(SCATTER_BOKSH_VS_OUT In) : SV_TARGET0
{
    const float coc = In.Color.a;
    // 表示範囲が前ボケ,後ボケ境界からはみ出すならば描画終了(後述)
    if ((coc > 0.0f) && (In.UV.z >= 0.0f)) discard;
    if ((coc < 0.0f) && (In.UV.z <= 0.0f)) discard;

    float3 color = In.Color.rgb;
    float2 rPos = In.UV.xy;
    if (coc < 0.0f) rPos *= 1.0f; // 前ボケのときの形状反転

    // ボケ形状の計算
    const float deltaTheta = 2 * PI / APERTURE_BLADE;
    // 描画点の光軸中心からの角度 (0~2pi)
    float theta = atan2(rPos.y, rPos.x) + PI + APERTURE_ROTATE;
    // APERTURE_BLADE角形のi番目頂点の角度を基準にした相対theta (0~2pi/APERTURE_BLADE)
    const float modTheta = frac(theta / deltaTheta)*deltaTheta;
    // bokehRadius=1.0としたときの,APERTURE_BLADE角形の境界となる境界
    const float effBound = 1.0f - (1.0f - APERTURE_CIRCULARITY)*(1.0f - cos(deltaTheta / 2.0f))*sin(APERTURE_BLADE / 2.0f*modTheta); 
    const float bokehRadius2 = rPos.x*rPos.x + rPos.y*rPos.y; // (0~1)
    const float effBokehRadius = sqrt(bokehRadius2) / effBound;
    // ボケ形状範囲外ならば描画しない
    if (effBokehRadius > 1.0f) discard;

    // ボケの形状による減衰(明度)を計算(0.0~1.0)
    float attenuation = saturate(1.0f - pow(effBokehRadius, 10.0f)); // 後ボケは中心ほど明るい
    // 前ボケならば明度を反転,
    if (coc < 0.0f) attenuation = 1.0f - attenuation;
    // CoCの大きさによる減衰
    // 本来は面積に反比例するが,マスクによって,積分されるボケ減っているので,面積反比例だと,あまりに薄い
    // 厳密な計算をするべきだが,coc^-1の重みでなんとなく良さそうだったのでこれで
    const float cocAttenuationWeight = min(1.0f, 1.0f / coc);
    attenuation *= cocAttenuationWeight;
    // 明度の計算
    color.rgb *= attenuation*APERTURE_BRIGHTNESS_SCALE;

    return float4(color, coc);
}

ボケの内部の色味だとか収差だとかは今回は適当に作っています。 ボケ形状を作成する部分は,APERTURE_BLADEで{n}角形を指定し,APERTURE_CIRCULARITYでボケの円形度合いを決めています。

f:id:hikita12312:20171011104928p:plain:w300

円弧を多角形の辺を軸と見たときのsinカーブに近似できるとして計算しています。 実際にPixelShaderから出力されたボケ形状は以下の画像のようになりました。適当な近似でもそれっぽくなっていると思います。

f:id:hikita12312:20171011105944p:plain

本来ボケの面積に従って明度が反比例していくべきですが,今回の場合ですとマスクによってボケの総量が減っているので,単純に面積に反比例だと色が薄くなりすぎます。 アドホックに直径に反比例という形で明度を減衰しています。

スキャッターボケテクスチャの作成

PixelShaderで作成したボケテクスチャを加算合成してテクスチャに書き出します。 このとき,入力テクスチャと同じサイズのテクスチャではなく,入力サイズの高さを2倍にしたアスペクト比で,異なる倍率の縮小バッファを3枚用意しました。

f:id:hikita12312:20171012102919p:plain:w400

VertexShaderで頂点を膨らませるときに細工をして,前ボケならばテクスチャの上半分,後ボケならばテクスチャの下半分に書き出すようにします。 カメラのボケは前ボケと後ボケで反転したり収差が変わったりして異なる形状になりますが,それを分離して表現することが可能となります。

f:id:hikita12312:20171012104625p:plain:w600

さらに,縮小バッファとして用意した異なるサイズ毎に,VerterxShaderでの膨らましとPixelShaderでの書き込みの一連のパスをそれぞれ行います。 大きなサイズのボケを書き出すときに,膨らんだスキャッター矩形の内部のピクセル数が多ければ多いほど負荷が高くなりますが, 大きいサイズのボケほど小さい縮小バッファを使うようにすれば負荷を抑えることが出来ます。 大きいボケには,それほどディティールが必要ないので縮小バッファで十分です。 ドローコールは3倍に増えますが,大きいボケの負荷を下げることが出来るので全体としての負荷は下がります。 さらに,ボケのサイズ毎に3つのテクスチャに分類して書き出すことで,ボケの前後関係を分けて書き出すことができることもメリットです。

結果

出来上がったスキャッターボケテクスチャをギャザーボケの結果に加算合成することで,最終出力が得られます。 スキャッターボケテクスチャのアルファ値にそのボケの生じた距離を格納することで,合成を行うときに後ボケをマスクをすることもできます。
(2017/10/12現在,合成周りがまだ甘いです...)

以下が結果です。 f:id:hikita12312:20171012105802p:plain:w600 こちら五角形の後ボケ,ある程度大きいボケでもキレイに表示されています。

f:id:hikita12312:20170926225548p:plain:w300
ギャザーボケ六角形
f:id:hikita12312:20171012105954p:plain:w300
スキャッターボケ六角形

フラットなブラーの結果と比較しました。 ギャザーベースのボケよりハッキリボケの輪郭が出ていて,大きいボケが表現できています。

f:id:hikita12312:20171012110415p:plain:w600

今度は円形ボケにして接写的に焦点を手前に合わせてみました。 だいたい良いんですが,一部の後ボケがマスクされていなかったり, ギャザーボケとスキャッターボケの合成が甘くて,ギャザーボケの結果が薄っすら見えてしまったりと改善の余地はありそうです。

倍率色収差の実装

倍率色収差

今回は簡単に出来そうな倍率色収差を実装してみます。 収差とは,レンズを通る光の波長の違いによるズレによる現象のことですが,倍率色収差は画面の周辺部にかけて色がズレていく現象のことだそうです。

http://cweb.canon.jp/eos/special/dlo/factor/index02.html
https://docs.unity3d.com/jp/540/Manual/script-VignettingAndChromaticAberration.html

実装

基本的な実装は,元となるテクスチャに対して幾つかのチャンネルでマスクをかけたものをそれぞれ拡大して加算合成すれば実現できます。

f:id:hikita12312:20171005235956p:plain:w600

#define CHROMATIC_ABERRATION_ARRAY_NUM 5
static const float3 s_aChoromaticAberrationFilter[CHROMATIC_ABERRATION_ARRAY_NUM] =
{
    float3(0.0f, 0.0f, 0.5f),
    float3(0.0f, 0.25f, 0.5f),
    float3(0.0f, 0.5f, 0.0f),
    float3(0.5f, 0.25f, 0.0f),
    float3(0.5f, 0.0f, 0.0f)
};

float4 PS_ChromaticAberration(PPFX_OUT In) : SV_TARGET0
{
    const float2 uv = In.UV.xy;
    float4 outputColor = float4(0.0f, 0.0f, 0.0f, 1.0f);

    float2 screenPos = TexCoordToScreenSpace(uv);
    for (uint i = 0; i < CHROMATIC_ABERRATION_ARRAY_NUM; ++i)
    {
        const float2 tempUV = ScreenSpaceToTexCoord(screenPos * (1.0f - CRHOMATIC_ABERRATION_SCALE * (i + 1) / CHROMATIC_ABERRATION_ARRAY_NUM));
        const float3 mask = s_aChoromaticAberrationFilter[i];
        const float3 srcColor = SampleLod0(g_SrcTexture, g_LinearSampler, tempUV).rgb;
        outputColor.rgb += srcColor * mask;
    }

    return outputColor;
}

以下が実装結果です。

f:id:hikita12312:20171006000516p:plain:w300
倍率色収差無し
f:id:hikita12312:20171006000534p:plain:w300
倍率色収差有り

f:id:hikita12312:20171006000534p:plain:w600

倍率色収差をかけると,画面の端の方ほど,色がズレていることが分かるかと思います。

フラットなブラー

ボケの形

被写界深度表現を実装しましたが, なんとなくガウスブラーで画面をボカシていました。 実際はカメラのボケというようのは,ガウシアンのボケではなく,カメラの絞りの形状による円形だったり六角形のフラットなボケとなるはずです。 今回はガウスブラーではない,カメラの絞りの形状のような形となるブラーの作り方を考えます。

ガウスブラー

まずは,ブルームや被写界深度を実装したときに作った,ガウスブラーの画像を比較までに置いておきます。

f:id:hikita12312:20170926223258p:plain:w600

f:id:hikita12312:20170926223226p:plain:w300
ブラー無し
f:id:hikita12312:20170926223258p:plain:w300
ガウスブラー

なんだかボケの輪郭がボヤッとしてますね。

ディスクブラー

円形のボケを実装します。以下のスライドの134ページ目あたりを参考にしました。

https://www.slideshare.net/siliconstudio/cedec-2009imagire-day-2009

f:id:hikita12312:20170926223603p:plain:w300

上の図が9x9のディスクブラーを作成するときのサンプリングの概略図です。
シェーダーのBilinear補間を考慮してサンプル位置とサンプル重みを決定します。 円に完全にフェッチする4x4(2x1,1x2)のテクセルの矩形が含まれている場合には,目的のテクセルの中央を等しい重みでサンプルしますが, 円周がフェッチするテクセルの矩形に含まれる場合には,含まれる面積を考慮してサンプリング位置を円の内側へずらし,重みも面積に比例するように調整します。 また,ブラーをかけて元の画像よりも明度が変わるのを避けるために,サンプリングする重みは正規化して総和が1となるように調整します。

それで出来上がった結果がこちらです。

f:id:hikita12312:20170926224131p:plain:w600

f:id:hikita12312:20170926223258p:plain:w300
ガウスブラー
f:id:hikita12312:20170926224131p:plain:w300
ディスクブラー

輝度が高い部分のボケが円形のサンプリングに従ってキレイに丸くなっています。

六角形ブラー

今度は六角形のボケを実装します。以下の記事を参考にしました。

https://colinbarrebrisebois.com/2017/04/18/hexagonal-bokeh-blur-revisited/

実現方法として,まずは1回目のパスで六角形の垂直方向の対角線のブラーと対角120度方向のブラーのMRTを行います。 2回目のパスで1回目の結果を六角形になるように延ばすことで,六角形を作ります。

f:id:hikita12312:20170926224800p:plain:w600

詳しいシェーダーコードなどは,参考にした記事を参照してください。   1パス目のMRTを使って作った2本の方向のブラーが頭が良くて,2回めのパスで六角形の余った領域を240度方向への1回のブラーでキレイに埋めています。 実は上の図で表した方法は,参考にした記事から少しだけ実装が変えていて, 垂直方向に関連するブラーをするときに+1のオフセットを与えて重なる領域を作らないようにすることで,六角形の対角が重なって見える問題を解決しています。

半径{r}の六角形を作るときに,{2r+2r}回程度のテクセルフェッチで実現できます。 MRTを使っている分,コスト面では劣りますが,実質ガウスブラーと同程度のサンプル数で実現できます。

f:id:hikita12312:20170926225548p:plain:w600

f:id:hikita12312:20170926223258p:plain:w300
ガウスブラー
f:id:hikita12312:20170926225548p:plain:w300
六角形ブラー

実際に適用した結果です。六角形が出来ていることがわかります。

ディザリングの実装

ディザリング

トーンマップやガンマ補正を行うと, 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
変換後