あいも変わらず Shirley の Realistic Ray Tracingを読んでいるのですが、その中にトーンマッピングのネタがありました。トーンマッピングは、かなりピクセルごとの並列的な処理なので、久しぶりのシェーダネタとして実装してみました。
今回のプログラムは、次のものです。
まぁ、いつものように適当にファイルが入っています。 今回は、レイトレースした画像をテクスチャとして張り付けているので、レイトレースに関するプログラムも入っています。 それらは今回の話とは関係ないので、大事な部分だけを抜粋すると、次のファイルだけに注目すればいいことになります。
main.cpp | メイン関数 |
main.fx | シェーダプログラム |
他にも、実行ファイル、リソースファイル、プロジェクトファイルが入っています。
トーンマッピングとは、画像を綺麗に見せるための手法です。
HDRが流行になってきて、1.0 以上の光も自然に扱うようになってきました。
ただ、現在のディスプレイは1.0以上の光を表現する方法がありません。
一方、人間の目などは、明るい屋外や暗い室内など、
ほとんどの景色に関して光の総量が変わってもいい感じに見れるように入ってくる光の量を自動的に調整します。
この目に入る光の調整をレンダリング過程として処理して、LDRの環境でもHDRの広いレンジの光の画像を自然に見れるように調整するのがトーンマッピングです。
下の画像がトーンマッピングする前と後の画像です。
トーンマッピングする前は明るい部分は全て一番強い色に飽和してどうなっているのか良く分からないのに比べて、
トーンマッピングした後の画像は、光が強い部分は直接光源を見ている部分だけになっており、
それ以外の部分は、光が強い中でも濃淡が分かるように調整されています。
なお、トーンマッピングでは、1つの画像に関して色調整するのみなので、
時間とともに光の量を調整する明順応や暗順応には対応していません。
トーンマッピングは、写真の分野で古くから取り組まれており、ネガに写した景色をどのように焼き付ければ一番見栄えが良くなるかということで研究されていました。
CG の分野では、2002 年の Photographic Tone Reproduction for Digital Images (Reinhard et.al., Transactions on Graphics(Proc. SIGGRAPH '02), 2002) の論文が一番有名でしょうか(この中に Shirley もいますね)。
今回は、この論文に載っている手法を実装します。
トーンマッピングでは、最初にシーン内の基準となる明るさを決めます。
ここでは、光の強さに関する対数平均を採用します。
_ 1 Lw = exp(-Σlog(ε+Lw(x,y))) N
ここで、和は全ての画素(x,y)で計算し、ピクセル数の合計がNになります。
Lw(x,y) は、各画素における輝度にです。
εはlogが発散しないための小さな定数になります。
この平均輝度を用いて、各ピクセルの輝度をスケーリングします。
_ L(x,y) = a * Lw(x,y) / Lw
a は、"key value" と呼ばれるもので、 今回は 0.18 を用います。この値が大きいほどシーン全体の明るさは明るくなります。
次に、明るい部分と暗い部分のバランスを取ります。
HDR 画像では、明るい部分の輝度はとてつもなく明るくなります。
上の線形の調整では暗い部分の情報が消えてしまうので、
明るい部分の情報は間引きして暗い部分の解像度を高めるといい見栄えになります。
ということで、次の変換式を使って、輝度の調整をします。
L(x,y)(1+L(x,y)/Lwhite2) Ld(x,y) = ---------------------- (1+L(x,y))
ここで、Lwhite は、一番強く(白く)表示される輝度の強さで、
今回は、一番強いピクセルの輝度を採用します。
この数式を使えば、輝度の低いところでは、色情報が線形に変化し、
輝度が非常に強くなると輝度の変化に対する色の変化はにぶっていきます。
さて、トーンマッピングの実装ですが、複数のステップに分けて処理を行います。
1つ目が、対数平均を求めるための輝度および対数の計算です。
2つ目が、縮小バッファを使って、平均値を求めるとともに一番強い輝度を求ます。
最後に、縮小バッファを使って1テクセルにまで落とした情報を使ってトーンマッピングをします。
下の画像は、対数、輝度計算および縮小バッファを撮影したときテクスチャです。
一番左が元画像で、その右が、r 成分に輝度 gb 成分に輝度の対数を格納しています
(4テクセル同時に処理をして、画面サイズを半分にしています)。
そこから、右に行くにつれて、1/4に縮小して平均を取ります。
なお、全てIEEE フォーマットのバッファを使用しています。
さて、実際のプログラムです。
最初のパスでは、対数計算するとともに、輝度の最大値を求めます。
輝度は、YCbCr の Y 値を使います。
後は、赤成分は輝度の最大値を入れるということで、max 命令を入れ子に使って4つのテクセルの最大値を求めます。
それ以外の成分には、対数を取ったものを平均化します。
main.fx 0084: float4 LogPS( LogVS_OUTPUT In ) : COLOR 0085: { 0086: const float3 RGB2Y = float3( +0.29900f, +0.58700f, +0.11400f ); 0087: const float EPSILON = 0.00001; 0088: float4 output; 0089: 0090: float l0 = dot( RGB2Y, tex2D( Sampler, In.TexCoord0 )); 0091: float l1 = dot( RGB2Y, tex2D( Sampler, In.TexCoord1 )); 0092: float l2 = dot( RGB2Y, tex2D( Sampler, In.TexCoord2 )); 0093: float l3 = dot( RGB2Y, tex2D( Sampler, In.TexCoord3 )); 0094: 0095: float l_max = max(max(l0,l1),max(l2,l3)); 0096: 0097: float total = log( EPSILON + l0 ) 0098: + log( EPSILON + l1 ) 0099: + log( EPSILON + l2 ) 0100: + log( EPSILON + l3 ); 0101: 0102: output.r = l_max; 0103: output.gba = 0.25f * total; 0104: 0105: return output; 0106: }
次のステップは、情報を1テクセルにまで小さくすることです。
赤成分は、最大の値を求めるので、max 命令をこれでもかというほど入れ子にします。
それ以外の成分は、平均を取るだけなので、和を取って、テクセル数(16)で割ります。
サンプリングするテクセルは、レンダリングするピクセルの右下4x4の16テクセルにしています。
main.fx 0173: float4 SmallPS( SmallVS_OUTPUT In ) : COLOR 0174: { 0175: float4 output; 0176: 0177: float4 t0 = tex2D( Sampler, In.TexCoord0 ); 0178: float4 t1 = tex2D( Sampler, In.TexCoord1 ); 0179: float4 t2 = tex2D( Sampler, In.TexCoord2 ); 0180: float4 t3 = tex2D( Sampler, In.TexCoord3 ); 0181: float4 t4 = tex2D( Sampler, In.TexCoord4 ); 0182: float4 t5 = tex2D( Sampler, In.TexCoord5 ); 0183: float4 t6 = tex2D( Sampler, In.TexCoord6 ); 0184: float4 t7 = tex2D( Sampler, In.TexCoord7 ); 0185: float4 t8 = tex2D( Sampler, In.TexCoord0 + float2(0,2.0*g_fInvTexSize) ); 0186: float4 t9 = tex2D( Sampler, In.TexCoord1 + float2(0,2.0*g_fInvTexSize) ); 0187: float4 ta = tex2D( Sampler, In.TexCoord2 + float2(0,2.0*g_fInvTexSize) ); 0188: float4 tb = tex2D( Sampler, In.TexCoord3 + float2(0,2.0*g_fInvTexSize) ); 0189: float4 tc = tex2D( Sampler, In.TexCoord4 + float2(0,2.0*g_fInvTexSize) ); 0190: float4 td = tex2D( Sampler, In.TexCoord5 + float2(0,2.0*g_fInvTexSize) ); 0191: float4 te = tex2D( Sampler, In.TexCoord6 + float2(0,2.0*g_fInvTexSize) ); 0192: float4 tf = tex2D( Sampler, In.TexCoord7 + float2(0,2.0*g_fInvTexSize) ); 0193: 0194: float l_max = max(max(max(max(t0.r,t1.r),max(t2.r,t3.r)), 0195: max(max(t4.r,t5.r),max(t6.r,t7.r))), 0196: max(max(max(t8.r,t9.r),max(ta.r,tb.r)), 0197: max(max(tc.r,td.r),max(te.r,tf.r)))); 0198: 0199: output.r = l_max; 0200: output.gba = (1.0/16.0)* (t0.gba + t1.gba + t2.gba + t3.gba 0201: + t4.gba + t5.gba + t6.gba + t7.gba 0202: + t8.gba + t9.gba + ta.gba + tb.gba 0203: + tc.gba + td.gba + te.gba + tf.gba); 0204: 0205: return output; 0206: }
最後に、各ピクセルをトーンマッピングします。
色情報をYCbCr色座標系に変換してから、Y 値に関してトーンマッピングし、
最後に RGB 色座標系に戻します。
main.fx 0256: float4 FinalPS( FinalVS_OUTPUT In ) : COLOR 0257: { 0258: const float3 RGB2Y = float3( +0.29900f, +0.58700f, +0.11400f ); 0259: const float3 RGB2Cb = float3( -0.16874f, -0.33126f, +0.50000f ); 0260: const float3 RGB2Cr = float3( +0.50000f, -0.41869f, -0.08131f ); 0261: const float3 YCbCr2R = float3( +1.00000f, +0.00000f, +1.40200f ); 0262: const float3 YCbCr2G = float3( +1.00000f, -0.34414f, -0.71414f ); 0263: const float3 YCbCr2B = float3( +1.00000f, +1.77200f, +0.00000f ); 0264: 0265: float4 info = tex2D( SamplerInfo, float2(0.5,0.5) ); 0266: float3 texel = tex2D( Sampler, In.TexCoord ).rgb; 0267: 0268: float coeff = 0.18 * exp( -info.g ); 0269: float l_max = coeff * info.r; 0270: 0271: // YCbCr系に変換 0272: float3 YCbCr; 0273: YCbCr.y = dot( RGB2Cb, texel ); 0274: YCbCr.z = dot( RGB2Cr, texel ); 0275: 0276: // 色の強さは補正 0277: float lum = coeff * dot( RGB2Y, texel ); 0278: YCbCr.x = lum * (1.0f+lum/(l_max*l_max)) / (1.0f+lum); 0279: 0280: // RGB系にして出力 0281: float4 color; 0282: color.r = dot( YCbCr2R, YCbCr ); 0283: color.g = dot( YCbCr2G, YCbCr ); 0284: color.b = dot( YCbCr2B, YCbCr ); 0285: color.a = 0; 0286: 0287: return color; 0288: }
SamplerInfo に先ほど計算した対数の平均や最大輝度が入っています。
係数 coeff や、スケーリングされた最大輝度 l_max は、毎回やる必要も無いので、
縮小するときの最後のパスで求めておくと高速化できるでしょう。
過去の論文を追っていないので、トーンマッピングの物理的な意味がよくわからんのですが、
どないなもんなんでしょうかねぇ。
なんか、適当に補正する手法にしか見えないのですが…
あと、表示するごとにトーンマッピングを掛けているのですが、 この方法だとレイトレでつかうCPU時間が減るので、 画面を更新するときだけ使うか、画面更新の間隔を押さえるかしないと駄目ですな。