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テクスチャで,右がトーンマップ+露光調整+ガンマ補正です。