16ビットカラー再考

f:id:hirasho0:20190214191934p:plain

こんにちは。技術部平山です。

以前インデクスカラー(パレット)について記事を書きましたが、 さらに原始的な圧縮である16bitカラー化についても記事にしようと思います。

ETC2やASTCによって使用頻度が激減しているというのになんで今更?と自分でも思いますが、 弊社はサービスが長い製品もありまして、 ETC2やASTCに移行するのが難しいケースもあります。 また、ETC2やASTCにおいても、今回紹介する単純な減色が基礎として使われており、 特性を知っておいて損はないでしょう。

なお、結論を述べておきます。

  • ETC2でいいなら以降の話は不要
  • パレット化の手間と負荷を許せるなら、大抵はその方が品質と容量において良い
  • Unityにそのまま16bit化させるとイマイチっぽいので自作インポータを用意した

コードはgithubに用意しておきました。 画質比較用に、すぐ実行できるWebGLビルドも置いてあります。

また、この記事で用いたUnityは2018.3.1です。

2つの16bit色

Unityがサポートする16bit色には二つの種類があります。

赤、緑、青、アルファにそれぞれ4bit(16段階)を当てるARGB4444と、 赤に5bit(32段階)、緑に6bit(64段階)、青に5bit当ててアルファを持たないRGB565です。 以下省略して「4444」および「565」と呼びます。

アルファチャネルにどれくらいのビット数が必要かは場合によりますので、 今回はアルファチャネルを考えずにRGBの画質だけを見ていきます。

f:id:hirasho0:20190214191942p:plain

左端が無圧縮の8888(RGBA32)です。この綺麗なグラデーションが、 中央の565になると若干階段状に見えます。 見えなければ幸せなので、特に気にしなくてかまいません。 RGBそれぞれ256段階あれば、使える色数は1677万色にもなります。 これが、565になれば6.5万色に落ちます。 白黒に近い画像の場合には、この例のように256段階あったものが 32から64段階に落ちます。その結果かなり汚なくなるわけです。

そして右端は4444でして、RGBの色数はわずか4096色、 白黒であれば16段階しかありません。 明らかに階段が見えてしまいます。

白黒でない色付きの絵でも見ておきましょう。

f:id:hirasho0:20190214191938p:plain

この場合565ではほとんど劣化がわかりませんが、 4444にすると色の境界線がひどいことになっているのがわかります。

減色の実際

では、「8bitを4bitにする」というのは実際どういう処理をしているのでしょうか。 まずはわかりやすいように、10進法で見ていきましょう。

戻す時の問題

元々100段階あったとします。 これを0から9の10段階に落とすとしましょう。 簡単なのは、10の位だけ取り出すことです。 86→8、74→7、という具合ですね。

では、1桁に落としたデータから元の2桁に戻す時にはどうすればいいでしょうか?

10を掛ければ8は80になりますが、このやり方はマズいのです。 真っ白である99を圧縮することを考えればわかります。 99は9になり、これを戻す時に90にしたとすれば、真っ白が真っ白に戻りません。 白が表現できない、というのはちょっとマズいでしょう。

ここで、「1の位は10の位と同じ数字を入れる」とすると状況が良くなります。

例えば9を2桁に戻す場合、9を二つ並べて99にするのです。 これなら白くなります。 8は88に、7は77に、という具合にやれば、0は0になり、 「黒は黒、白は白」が保たれます。

誤差の問題

元の2桁を1桁に落として、また2桁に戻した時、元の値とどれくらいズレてしまうのでしょうか。 このズレの大きさを「誤差」と呼びます。

例えば、99を9にした場合、これは99に戻りますから、誤差は0です。 90を9にした場合、これも99になるので、誤差は9になります。

誤差はできるだけ小さくなるように圧縮したいですね。 今の例で言えば、90を9でなく8にしていれば、88になるので誤差は2になります。 つまり、単純に10の位だけを取り出したのでは、最適な結果にはなりません。

誤差最小化

0から99の範囲を、0から9の範囲に圧縮する問題と考えると、話は簡単です。 最大値から最小値を引いた「幅」を考えましょう。 元の幅は99-0で99、圧縮後は9-0で9ですから、99の幅を9の幅に圧縮することになります。 よって、(99/9)=11で割ります。ここを整数でなく小数でやるのがミソです。

すると、例えば99はピッタリ9になり、90は8.18..になります。 あとは、四捨五入すれば近い方になりますよね。 四捨五入をプログラムで書けば、「0.5を足してから切り捨て」ですから、 今の処理をコードで書けば、

compressed = Mathf.FloorToInt((original / (99f / 9f)) + 0.5f);

という具合になります。誤差は最大で5です。 例えば94は94/11=8.545...なので四捨五入で9にされ、戻せば99になるので誤差5です。 単純に1の位を切り捨てていれば、90が99になるように誤差の最大値は9になりますから、 だいぶ改善することになります。

実際の色で式を作る

さて、今は簡単のために10進法でやりましたが、実際には2進法です。 0から255までの値を、4bitなら0から15、5bitなら0から31、6bitなら0から63に変換します。

99を9にして、戻す時には1の位にコピーして99、というのが10進法の時の話でしたが、 2進法でも同じです。

例えば、2進の11111111(255=0xff)を、1111(15=0xf)に圧縮した場合、 戻す時にはこの4桁を下に補って11111111にします。

101011111(175=0xaf)を、仮に1010(10=0xa)に圧縮した場合、 戻す時には上4桁の1010を下に補って、10101010(170=0xaa)にします。

5bitの場合も同じで、10101は上3bitを下にくっつけて10101101に、 6bitの場合は110011の上2bitを下にくっつけて11001111に展開します。 こうすることで、白を白に、黒を黒に戻すことができるのです。

全てのGPUがそうしているかはわかりませんが、こうしないGPUでは白が白に戻りませんので、 たぶんこれが普通かと思います。 例えば0xfは0xffに、0xeは0xeeに、0x8は0x88になるわけです。

では処理はどうなるでしょうか?実は10進とほとんど変わりません。

compressed4bit = Mathf.FloorToInt((original / (255f / 15f)) + 0.5f);
compressed5bit = Mathf.FloorToInt((original / (255f / 31f)) + 0.5f);
compressed6bit = Mathf.FloorToInt((original / (255f / 63f)) + 0.5f);

(99f/9f)(255f/15f)(255f/31f)で置き換えるだけです。

originalは0から255までが入っている変数と考えてください。 例えばTexture2D.GetPixels32()で 取ってきたColor32型の要素です。 これを、最大15、31、あるいは63になるように割って、0.5を足してから整数に切り捨てます。 単純切り捨てでは4bitで最大15の誤差が出ますが(0xf0=240が255にされる)、 このやり方なら誤差は最大8です(0xf7=247が255にされる)。

なお、floatへのキャストを介さず、全てを整数でやることもできます。

compressed4bit = ((original * (15 * 2)) + 255) / (255 * 2);
compressed5bit = ((original * (15 * 2)) + 255) / (255 * 2);
compressed6bit = ((original * (15 * 2)) + 255) / (255 * 2);

255/15で割るのは、15を掛けてから255で割るのと同じです。 計算を整数でやる時には除算を最後に持ってくる必要がありますから、 0.5を足すのは割り算の前にします。(255*2)=510で通分すれば良く、255を足してから510で割れば 0.5を足したことになりますね。ただし、整数の方が速いという保証はありません。

なお、整数版を高速化したければ、510で割る所を512で割れば、若干結果がズレますが、 ほとんど同じ結果をより高速に得られます(512で割るのは9bitの右シフトでできます)。 サンプルではそこまではしていません。

切り捨てとの比較

f:id:hirasho0:20190214191956p:plain

左が上の処理を自分で書いて16bit化したもので、右がUnityのインポータ設定を 4444(TextureImporterFormat.ARGB16) にして16bit化させたものです。違いがわかりますか? 右は明るい半分がより明るい方向にズレ、暗い半分はより暗い方向にズレます。 一番左端の白い所の幅が、右の方が広いのが見えるでしょうか?

この誤差をもっとわかりやすくするために、誤差画像も用意しました。

f:id:hirasho0:20190214191959p:plain

これは、元画像と圧縮画像をシェーダで引き算して、その差が大きいほど明るくなるようにした物です (DiffImageというコンポーネントとして用意してあります)。 左はどこでも似たような誤差ですが、右は画面の両端で誤差が大きく明るくなっています。 左端の明るい所では、例えば0xf0が0xffに切り上げられて誤差が15出ているような状態、 右端の暗い所では、例えば0x0fが0x00に切り捨てられた誤差が15出ているような状態に見えます。 どうやら、Unityに減色をやらせると切り捨てで変換するようです。

人間の視覚特性の問題

16段階に落とすと階段上に見えてしまうのは、 単に色数が少ないからというだけではありません。 人間の視覚の特性によります。 人間の視覚には、コントラストを強調するフィルタが常時かかっているのです。

側抑制(lateral inhibition) という仕掛けによるのですが、 例えば「明るい色の隣の少し暗い色はより暗く見える」 「暗い色の隣の少し明るい色はより明るく見える」 といったことが起きます。これによって見える帯を マッハバンドとも呼びます。

f:id:hirasho0:20190214191948p:plain

4444化したグラデーションを拡大してみた画像です。 縦の帯が5本見え、帯の中は基本同じ色なのですが、どうでしょう。 帯の中でも左側は暗く、右側は明るく見えませんか? それは、隣の帯の影響で、脳が勝手にコントラスト強調をかけた結果なのです。 ですから、隣の帯を隠してしまうとその影響はなくなりますし、 暗い帯を明るい帯と取り換えると、逆方向の強調がかかります。

f:id:hirasho0:20190214191953p:plain

画像の下半分を左右反転してみました。 私にとっては、さっきの画像よりも帯の中の色変化が小さくなったように感じられます。 ちょっとのっぺりしてませんか?

なお、このコントラスト強調効果には個人差があるようです。 これが強くかかる人ほど、段階を減らした時の劣化に敏感かもしれません。 誰かが「これで気にならない」と言っても、他の誰かは気にするかもしれないわけです。

ディザリング

色数を減らした時の画質の劣化を気づきにくくするには、 この側抑制を抑えてやるのが有効です。 大きな面積同士が隣合っていると側抑制の効果が見えやすくなりますが、 色をノイズ状に散らしてやれば、強調される線がバラバラになって 見えにくくなります。

加えて、人間の視覚の解像度には限りがありますから、 赤い花と青い花が交互に植えられた花畑を遠くから見れば、 紫に見えます。細かく色を散らすことで、 極端な話、白と黒しかなくても灰色に見える絵を作れます。

この「色を散らす処理」を ディザリング と言います。 今回はその中でも古くから使われていて実装が楽な、 Floyed-Steinberg(フロイド-スタインバーグ) 法を実装しました。 古くからunity用に公開されている実装も存在しますが、 今回は4444のみならず565用も欲しかったのと、 前述のように、そもそもビットを削る段階で誤差を減らす配慮をしたかったので、 まとめて実装を行いました。

結果は以下のようになります。

f:id:hirasho0:20190214191934p:plain

565と565Ditherの違いは気にならない方も多いかと思います。 今回は全てポイントサンプリングで描画しているため、 拡大すれば違いがわかりますが、 通常はバイリニアサンプリングで描画してディザリングの点々が ボヤけますので、なおさらどうでも良くなるかと思います。

一方、4444ではかなりの差が出ます。 拡大すると点々模様が明らかに見えますが、 等倍では見えにくくなり、実際にはバイリニアフィルタがかかるので なおさら見えにくくなります。イラストの画質で訴求するなら許容できないでしょうが、 そうでない部分であれば案外使えます。

グラデーションでも見てみましょう。

f:id:hirasho0:20190214191945p:plain

4444の縞模様がだいぶ見え辛くなっています。

Unityでの実装

Unityでこれを実装するには、 AssetPostprocessor クラスを継承したクラスを作り、 OnPreprocessTexture と、OnPostprocessTexture の二つを実装します。

単にUnityに減色させるだけであれば、Preprocessの方で importer設定を4444(ARGB16)なり565(RGB16)なりに書き換えて終わりで良いのですが、 今回は「インポート後にメモリ内にあるテクスチャデータをいじる」 という行程になるため、PostProcessも必要です。

Preprocess

まず、Preprocess側で変換元テクスチャを何らかの手段(サンプルではパス)で識別し、 CPUでの読み込みをサポートさせ、無圧縮に設定します。

importer.isReadable = true;
importer.textureCompression = TextureImporterCompression.Uncompressed;

オリジナルのテクスチャがこれによって「読み込み可能」かつ「無圧縮」 になれば、これをPostprocessで加工できるわけです。

Postprocess

次に、Postprocess側 では、インポートが終わってメモリ内にあるテクスチャデータに対して 減色とディザリングを行い、その後、4444や565へのフォーマット変更を行わせます。

以下が処理の本体部分です。

for (int i = 0; i < texture.mipmapCount; i++)
{
    pixels = texture.GetPixels32(i);
    ColorReductionUtil.FloydSteinberg(pixels, func, width, height);
    texture.SetPixels32(pixels, i);
    width = Mathf.Max(1, width / 2); // 次のサイズへ
    height = Mathf.Max(1, height / 2); // 次のサイズへ
}
EditorUtility.CompressTexture(texture, format, quality: 100);

全ミップレベルに対して、GetPixels32()でデータを得て、 FloydSteinberg()でディザをかけながら減色し、 これをSetPixels32()でテクスチャに書き込み、 最後にCompressTexture() でフォーマットを4444や565に変更します。

この結果が、プラットフォームごとの「インポート済みテクスチャ」として、 Library以下に保存されるわけです。 Assetsの下にある元のファイルは減色やディザをかける前の状態のままです。

なお、減色処理(FloydSteinberg())は8888(RGBA32)のままで行います。 各画素の色を「一旦減色してから桁を補って戻した値」に置きかえます。 つまり、0xf0を0xeにして書きこむのではなく、それをさらに0xeeに戻してから書き込むわけです。 これを0xeにするのはEditorUtility.CompressTextureにてUnityが行います。 Unityが正しく誤差を最小化してくれても、あるいは単純なビットの切り捨てしか行わなくても、 どちらにしても正しく誤差の小さな画像になります。

減色とディザの実装本体

減色とディザリングの実処理であるFloydSteinberg()や、それに関連する実装は、 ColorReductionUtil.csにあります。 中身は難しくありませんが、ビット演算に慣れていないと、減色及び復元の処理は わかりにくいかもしれません。例えば、5bitに減色して、これを8bitに戻す時の処理は以下のようになります。

var r = ((x.r * 62) + 255) / 510; //5bit
r = (r << 3) | (r >> 2);

上の行は先程の式ですね。この後、この5bitから8bitを生成します。 5bitをABCDEとしましょう。上の3bitABCを下に補って、ABCDEABCを作ります。 5bitを左に3bitシフトし(=8を掛け)、上3bitが一番下に来るように2bit右にシフト(=4で割る) したものを加算します。

r = (r * 8) + (r / 4);

と書いても同じです。

運用

今回のサンプルでは、特定の名前がついたフォルダにある画像に対して、 減色とディザリングを行っています。

プロジェクトによっては、フォルダ名ではなく、ファイル名の方が便利、 という場合もあるかもしれません。私個人は 「足りている限り自由度は低いほど良い」と考えているので、 まずはフォルダでやってみました。東京プリズンもフォルダ名での制御をしていました。

また、圧縮の種類の設定についてですが、 サンプル用のインポータ(TestColorReductionImporter)は、ディザのOn/Offと、4444か565かを自由に選べますが、 より実戦を意識したインポータ(ColorReductionImporter)は、アルファチャネルの有無によって自動で4444か565が選ばれ、 常にディザがかかります。 これも「足りている限り自由度は低いほど良い」主義によります。

皆さんはどうお考えでしょうか。

おわりに: ETC2で良くない?

東京プリズンでは、 不透明であればETC1かPVRTCを用いましたが、 半透明がある場合、特にエフェクトの類はほぼほぼ4444でした。 OpenGLES2の機械も対象としましたので、ETC2は使えなかったのです。

RGBにETC1かPVRTC、アルファにAlpha8、という2枚構成にする手もありますが、 コンポーネントやシェーダが専用になり、アトラスのことも考えると 運用がかなり面倒です。 加えてSpineで用いている部分は手が出しにくいという問題もあります。 そこで、「容量が半分にしかならず汚ないが、4444で我慢する」という決断をしたわけです。

正直、4444は汚ないです。ディザをかけても十分な品質ではなく、 アルファチャネルが16段階しかないこともかなり残念です。 とはいえ、雲や煙などのぼんやりした画像であったり、 高速でアニメーションするエフェクト類であれば、 許容できる範囲ではありました。

とはいえ、結論は 「ETC2で良くない?」 です。 OpenGLES3以降だけを相手にするのであれば、まずはETC2を検討するのが良いでしょう。

なお、次回も結論が 「ETC2で良くない?」 で終わる記事をお届けします。

おまけ: RGB332

f:id:hirasho0:20190214191931p:plain

調子に乗って、赤3bit、緑3bit、青2bitでディザをかけた絵も作ってみました。 Alpha8にRGBを入れられます。個人的にはどことなく懐しい雰囲気です。 同様にして1bit白黒にすれば、Alpha8に8ピクセル入れられますから、 1024x1024がたった128KBに収まりますよ。 何か面白い使い途が見つかると良いのですが。