sRGB (Color Texture) を正しく扱おう

2024年8月23日

なんか見た目がおかしいな?と思ったときに読んでほしい記事です。

※この記事では VRChat の、特にアバター改変での文脈で話を進めていきますが、内容自体はワールド制作や一般の Unity ゲーム開発でも有効なものです。

はじめに

アバターで使うテクスチャのインポート設定、みなさんはどれくらいいじっていますか? 代表的なところでは Mip Streaming1Max Size2、ほかには Crunch Compression3 などがよく取り沙汰されていますね。 VRCSDK に怒られたり最適化関連で重要な項目なので、実際問題としてこれらの項目を適切に設定するのは重要です。

……が、この記事で僕がそれらと同じぐらい正しく設定するべきだと主張したい項目があります。

Texture Import Settings
知名度がそんなに高くなさそう

sRGB (Color Texture) という項目です。

これ何?

Unity 公式ドキュメントには次のように記述されています:

sRGB (Color Texture)

テクスチャがガンマ空間内にあるかどうかを示します。 アルベドやスペキュラー色などの非 HDR カラー テクスチャに対してこのプロパティを有効にします。 滑らかさやメタリック性の値など、正確な値が必要な情報がテクスチャに保存されている場合は、このプロパティを無効にします。

デフォルトのインポート設定 - Unity マニュアル

この説明で完全に理解した人はもう大丈夫です(さては 3DCG (んちゅ)だな?)。

要約すると、テクスチャに記録されている値をどのように扱うかを決めるものです。

どう設定すればいいの?

まあ上記ドキュメントの通りなんですが、それが「色を表していて」、かつ非 HDR であれば、チェックを入れたままにしてください。そうでなければ、チェックを外してください。

色を表している、というのはつまり、色を表しているということです(進次郎構文)。メインカラーや MatCap テクスチャが該当します。マテリアルで最終的に色として見えるなら対象です。

一方、そうでないテクスチャとは、例えばアルファマスクなどのマスクテクスチャ、滑らかさ(Smoothness)金属度(Metallic)のテクスチャなどです。AO テクスチャも該当します。 マテリアルの最終出力で色として見えないな~、と思ったらそれは高確率で非カラーテクスチャになります。 ……え?エディタではテクスチャ全部色に見えるだろって?そりゃそうです、色にして見せているんですから。

非 HDR であるについては、基本的にほぼ全てのテクスチャは非 HDR なので当てはまると考えて問題ありません。HDR なテクスチャは多くの場合 .hdr (Radiance HDR) や .exr (OpenEXR) といった拡張子をもっています。

ちなみに HDR テクスチャとは通常の(非 HDR な)テクスチャよりも広範囲・高密度の輝度を記録できるようなテクスチャです。簡単にいうと 1.0 よりも高い輝度を記録できます。 Skybox とか Cubemap fallback は HDR なことが比較的多いですね。

具体的に何が起きるの?

シェーダーで取得される値がテクスチャに記録されている値から変換されたりされなかったりします。

前提として、テクスチャに記録されている値は整数で 0~255 ですが4、実際には 1/255.0 されて 0.0~1.0 に正規化されるということを覚えておいてください。

簡単のために、先にチェックを外した場合について説明します。このとき、テクスチャに記録されている 0.0~1.0 の値は そのまま マテリアルのシェーダーでも 0.0~1.0 として取得されます。 テクスチャに 0.5 (127) って書いてあったらシェーダーでも 0.5 だし、もちろん 1.0 (255) だったらシェーダーでも 1.0 です。わかりやすいですね。

今度はチェックが入っている場合です。このとき、GPU が5テクスチャに記録されている値をある式にしたがって変換するため、シェーダーでは元の値とは異なる値が取得されます。 元が 0.0 と 1.0 のときはそのまま 0.0 と 1.0 なんですが、テクスチャの値が 0.5 のときシェーダーで得られる値はだいたい 0.22 です。アルファマスクに使ったらびっくりするぐらいスケスケになってしまいますね。 先ほど言及した変換に使うある式、というのは、ざっくり $ y = x^{2.4} $ と言われています6

なんでそんなことしないといけないの?

人間の目の特性のせいです。あと Unity 的な都合です。

こまけ~話になります。興味ある人だけ読んでくれ。

人間の目の話

人間の目は対数的な特性をもつと言われています。目に入ってきた光のエネルギー量と実際に感じる明るさは比例関係にあるわけではないんですね。 逆に言うと、人の目で感じる 100% の白と比べたとき、 50% のグレーは光のエネルギー量が半分になっているというわけではないんです。だいたい 22% のエネルギー量でそう感じるらしい。

真夏の太陽からろうそくの炎まで、エネルギーとして測ると相当に幅がありますが、感じる明るさとしてはいい感じに圧縮できるんですね。 詳しいことは近くの人間の身体に詳しい方に訊いていただきたく……

Unity の話

Unity にはリニアワークフローとガンマワークフローというものがあります。 VRChat のプロジェクトはリニアワークフローで設定されていますし、Unity プロジェクトのデフォルトもそうなっているはずです。

まずガンマワークフローですが、シェーダーから得られた値はディスプレイに表示される値になっています。シェーダーが最終的に7 0.5 と言ってきたら、ディスプレイにも 0.5 (127) を出すわけです。

一方リニアワークフローでは、シェーダーで扱う値は光のエネルギー量を表します。シェーダーが 0.5 と言ってきたらエネルギー量として 0.5 です。 このように光のエネルギー量を扱うと、物理的に正しくなるので正確な結果が得られるようになります。例えば複数の光を足したときに変な色にならなくなります。

つながったぞ……!点と点が……!

先にも言ったとおり、人の目に 50% の輝度で映る光のエネルギー量は 22% ぐらいなわけです。 だから、シェーダーで計算するときにはその色は反射率を約 0.22 として計算してあげると物理的に正しい計算になります。

ところが、テクスチャには 50% の色はそのまま 50% として記録されている。 これをそのまま渡すと反射率が 0.5 になってしまい、物理的に正しくない計算になってしまう。

この不協和を解決しているのが、 sRGB (Color Texture) のチェックを有効にすること発生する sRGB to Linear 変換です。

その他 Q&A など

なんでテクスチャに記録する値が実際の光のエネルギー量じゃないわけ?面倒じゃん

知らん。今更僕に言われても困る。

……というのは冗談で、こうする方が情報の保持の上で有利だったからという説が有力です。

対数特性ということは暗いところに敏感ということになります。 0~255 の整数で一定範囲の輝度を表そうとしたとき、光のエネルギー量で均等に分割してしまうと暗い部分が無駄に荒く、明るい部分は無駄に細かくなってしまいます。 暗い部分をより 刻んで 表現したいのでこうなっている、というところですね。多分。

なお HDR テクスチャの場合、そもそも表現できる範囲が大きいというのもありますが、内部表現が 16bit 浮動小数だったりするので8、暗いところは浮動小数の特性として細かく記録できるという大きな違いがあります。 そういう背景もあり、 HDR テクスチャは色を表していてもリニアな値が格納されていることが多いです。

ノーマルマップは?色じゃないけど sRGB (Color Texture) なんて項目なくない?

ノーマルマップはその時点で 100% 色を表していないので自動的に Linear 扱いになってるんだと思います。

Unity にインポートされた後のノーマルマップの内部表現はまた別の話になるので別の記事を読んでね。

あとがき

本当はもっと細かく書きたいこと色々あるけど本筋から外れすぎるので端折りました。気になった人は調べてみてください。地獄が広がっています。

色を表してなければ、 sRGB (Color Texture) のチェックを外す。 これだけでも覚えてってくれよな!


  1. 最初から入れといてほしい ↩︎

  2. 画像は 4K でもいいから設定は最大でも 2K にしてほしい ↩︎

  3. 入れたきゃ自分で個別に入れるから切っといてほしい ↩︎

  4. 色深度 8bit である仮定で話を進めます ↩︎

  5. ドライバなのか GPU なのかグラフィックス API なのか詳しくは知りませんが、まあひっくりめて GPU がということにしておきます ↩︎

  6. 実際には 0 付近と 1 付近が微妙にこの曲線に乗ってないらしいです ↩︎

  7. 最終的に、とわざわざ書いているのは、入力はリニア空間であるかのように処理されるからです。余計ややこしいわ ↩︎

  8. そうじゃないこともあるけど値の表現が浮動小数ベースなのが最有力だと思う ↩︎