輝度+色差でテクスチャ圧縮(YUVあるいはYCbCr)

f:id:hirasho0:20190218134125p:plain

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

今回は以前書いたインデクスカラー画像に関する記事、及び、 16bitカラー画像に関する記事の続編です。 今回はRGBを「輝度」と「色差」に変換してから圧縮する技法を紹介します。特徴は、

  • GLES2で出せる
  • そこそこ良好な画質
  • 不透明で12bit/画素(実質3/8の圧縮率)、アルファ対応すれば20bit/画素(5/8の圧縮率)。
  • 圧縮は高速でツールを選ばない
  • 変なシェーダなしでバイリニアフィルタ可能
  • 専用コンポーネント/シェーダで面倒くさいのはインデクスカラーと一緒

といったところで、結論は変わらず ETC2で良くない? となります。

なお、すでにUnity用の実装が公開されている ChromaPackと だいたい同じもので、

  • 元テクスチャが2のべき乗(POT:PowerOfTwo)であれば、それを維持できる
  • アルファチャネルへの配慮

といったあたりが少し異なるだけです。

例によってソースはgithubに公開しております。 また、画質比較がすぐできるように動くものをWebGLで置いておきました

解像度を削る、という選択肢

インデクスカラー圧縮は「色の数を減らしてテーブル参照に置き換えること」で、 16bit化は「色に割り当てるビット数を減らすこと」で圧縮を図りました。 いずれも、画素の数、つまり解像度には手をつけていません。

しかし、画素の数を減らせば、つまり解像度を落とせば容量は減るわけで、 それも当然考えるべきでしょう。

実際、現場においては、フォーマットを云々する以前に、 解像度が適正かを真っ先に検討すべきです。 動かす機械の解像度が1920x1080だからといって、 画像の解像度を1920x1080にせねばならないわけではありませんし、 描画する解像度自体をハードウェア解像度より落とすこともよく行われました (個人的にはPS3時代が思い出深いです。性能が足りなくて解像度落としたことを明かす記事はたくさん残っています。これとかこれとか)。

弊社東京プリズンにおいては、 1136x640を標準解像度とし、 画像の多くはその解像度で等倍で出るサイズで用意していました。 1920x1080の機械では拡大されます。 スマホの小さな画面で出すのにそれ以上に精彩な画像を用意しても、 ダウンロード時間、ストレージ使用量、メモリ使用量、処理負荷、 開発時のコスト、等々の面で良くないと考えたからです。

そういうわけで解像度を削るのは大切なことなんですが、 ただ削るのは技術でなく商売の話です。 この記事では技術で解像度を削るお話をいたします。

色ごとに解像度を変える、という発想

人間の視覚にはムラがあります。

例えば、赤、緑、青に関して言えば、人間の視覚は緑に敏感で、青には鈍感です。 以前の記事で触れたRGB565という圧縮形式では、赤と青は32段階なのに、緑には64段階用意しています。 人間の視覚特性を考慮してのことです。 では、これを解像度に応用してみましょう。緑だけは元の解像度を保ち、 赤と青は半分に落としてしまう、というのはどうでしょうか?

f:id:hirasho0:20190218134120p:plain

左端が元画像、中央が今回の実験、右が単純に縦横半分にしたものです。

輪郭に緑が出てますね。使い物になりません。 そりゃそうか、という感じはありますが、 右の単純に半分にしたもののボケっぷりと比べてみてください。 元の容量の半分(RGBで24bit→12bit)に落とした割には、全体としての精彩感が 保たれている感じがしませんか?

YUVというやり方

さて、このアプローチをもっと賢くやったものが、YUVです(YCbCrとも)。

YUVでは、RGBを、輝度Yと、色差U及びVに変換します。 人間の視覚は明るさ、つまり輝度には敏感なので、 Yは元の解像度を残します。 一方、色の変化である色差には鈍感なので、 UやVは解像度を落としてしまいます。 大抵の動画がこうして圧縮されており、静止画ではjpegがこの手法を用いています。 由緒正しい手法です。

実装

エンコード

まず、RとGとBをほどよく混ぜて、明るさを表す輝度Yを作り出します。

ほどよくの具体的な方法はいろいろ提唱されていますが、 今回は古くから使われている ITU-R BT.601という方式を使いました。

static void ToYuv(ref Color32 pixel)
{
    const float yr = 0.299f;
    const float yb = 0.114f;
    const float yg = 1f - yr - yb;

    float y = (yr * pixel.r) + (yg * pixel.g) + (yb * pixel.b);

赤を29.9%、青を11.4%、残りは緑で58.7%混ぜて輝度Yを作ります。 人間の目は最も緑に敏感で、次が赤、青には鈍感、ということを反映しています。 Yの範囲は0から255です。

次は色差です。色差UとVは、それぞれ「青-輝度」「赤-輝度」 で計算し、これを-(255/2)から(255/2)の範囲になるようにほどよい値を掛け、 最後に(255/2)を足して0から255の値を作ります。

   const float uScale = 0.5f / (1f - yb);
    const float vScale = 0.5f / (1f - yr);
    float u = (pixel.b - y) * uScale;
    float v = (pixel.r - y) * vScale;
    u += 255f / 2f;
    v += 255f / 2f;
}

Uを例に見てみましょう。(b-y)で色差を作り、 これが(-255/2)から(255/2)の範囲を持つように、uScaleという値を掛けています。

uScaleはどう計算できるでしょうか。例えば「すごく青い色」を考えます。 bは255で、rとgは0とします。すごく青いですね。 すると、yは255*ybで、bは255なので、(b-y)255*(1-yb)です。 これが255/2になればいいのですから、uScaleを(255/2) / (255*(1-yb))にすればいいですね。 255が分子と分母にあるので消えて0.5/(1-yb)となり、コードと一致します。

あとは、0から255の範囲になるように、255/2を加えて完成です。 Color32に格納する場合は、四捨五入してbyteにキャストすれば良いでしょう。 四捨五入は「0.5を加えてから整数に切り捨て」で行えます。

なお、webでよく見かける、

Y =  0.299R + 0.587G + 0.114B
U = -0.169R - 0.331G + 0.500B
V =  0.500R - 0.419G - 0.081B

という式は、r,g,bそれぞれに掛かる定数を前もって計算すれば出てきます。 若干速くなると思いますが、定数が9個もあり、書き写しそこねてバグるのも嫌なので、 今回は定数2個で済む書き方にしました。

エンコード結果

こうして分解したテクスチャが以下です。

f:id:hirasho0:20190218134132p:plainf:id:hirasho0:20190218134129p:plain

左がY、右がUとVです。 元々275x108で、yはそのままですが、uvは半分にしたものを連結してあります。

3枚に分けてもいいのですが、ファイル数が増えて邪魔なのでuvはくっつけておきました。 ChromaPack ではyuv全てを1枚にくっつけていますが、 そのためにテクスチャの幅が元の1.5倍になっており、 テクスチャのサイズを2のべき乗に保ちたい、という場合にそれが崩れてしまいます。 私は2のべき乗テクスチャが大好きですので、このような実装にしました (2のべき乗の方が描画が速い機械が過去にあったのです。現在の機械がどうかは調べていません)。 もし、テクスチャをリピートさせたい場合には、uvも分離した方が良いでしょう。

デコード

テクスチャができたら、今度は描画側です。シェーダでRGBに戻します。

v2f vert (appdata v)
{
    v2f o;
    o.vertex = UnityObjectToClipPos(v.vertex);
    o.uvY = TRANSFORM_TEX(v.uv, _MainTex);
    o.uvU = float2(o.uvY.x * 0.5, o.uvY.y);
    o.uvV = float2(o.uvU.x + 0.5, o.uvU.y);
    return o;
}

fixed4 frag (v2f i) : SV_Target
{
    half3 yuv;
    yuv.x = tex2D(_MainTex, i.uvY).a;
    yuv.y = tex2D(_UvTex, i.uvU).a;
    yuv.z = tex2D(_UvTex, i.uvV).a;
    half4 rgba;
    rgba.xyz = yuvToRgb(yuv);
    rgba.a = 1.0;
    return rgba;
}

half3 yuvToRgb(half3 yuv)
{
    const float wr = 0.299;
    const float wb = 0.114;
    const float uScale = 0.5 / (1.0 - wb);
    const float vScale = 0.5 / (1.0 - wr);
    const float wg = 1.0 - wr - wb;

    half3 rgb;
    rgb.b = ((yuv.g - 0.5) / uScale) + yuv.r;
    rgb.r = ((yuv.b - 0.5) / vScale) + yuv.r;
    rgb.g = (yuv.r - (yr * rgb.r) - (yb * rgb.b)) / yg;
    return saturate(rgb);
}

頂点シェーダではY,U,V用のテクスチャ座標を生成します。 フラグメントシェーダでやるよりも頂点シェーダの方が計算が減りますし、 フラグメントシェーダでテクスチャ座標を計算すると著しく遅くなる機械を 見たことがあります。

フラグメントシェーダでは、もらったテクスチャ座標で Y,U,Vをバラバラに3回tex2Dし、 yuvToRgb()でYUVからRGBに戻します。

さてyuvToRgbの中身ですが、これはインポート時の逆変換です。 シェーダでは0から255でなく、0から1なので、 先程(255/2)だった定数が0.5になっていたりはしますが、 基本は単なる逆変換です。計算を逆順に辿っただけです。 なお、バイリニアフィルタの結果元々なかった色が生まれ、 変換結果が0から1に収まらないことがあるので、saturateで0から1にクランプしています。

なお、これも前もってy,u,vに掛ける定数を計算しておけば、

rgb.r = yuv.r + (1.402 * yuv.b) - 0.701;
rgb.g = yuv.r - (0.344 * yuv.g) - (0.714 * yuv.b) + 0.529;
rgb.b = yuv.r + (1.772 * yuv.g) - 0.886;

といった具合に書けます。この方が速いでしょう。

結果

では結果を貼りましょう。記事冒頭の画像です。

f:id:hirasho0:20190218134125p:plain

中央が圧縮したものですが、私には左の無圧縮との違いがわかりません。 それで容量が半分になるなら、結構おいしいんじゃないでしょうか?

なお、比較のために、ディザ入り565で圧縮したものも右に用意しておきました。 これも結構綺麗で、拡大しない限りほとんど差がわかりません。 差がわからないなら容量が小さい方が良いでしょう。

YUVの弱点

さて、今の画像では圧縮によってあまり劣化せず、かなり使えそうな印象ですが、 弱点はないのでしょうか?

ないはずがありませんね。以下の画像をご覧ください。

f:id:hirasho0:20190218134135p:plain

4つの派手な色の領域がある画像です。 左が元画像、右が圧縮したものです。

字の輪郭が怪しいですね。縁取りをした覚えはありません。 赤と緑の境界あたりもおかしい気がします。 色の変化は解像度が半分なので、 急に色が変わるとおかしな色が出てしまうわけです。 最近のBC7やASTCなどと違って画一的な圧縮ですから、 そういう所のケアはできません。

アルファの問題

なお、YUVはRGB部分をどうにかする方法であり、 アルファチャネルは別です。 ChromaPackでも「透けているかいないか」の1bitだけをサポートしています。

しかし、「アルファチャネルを別のテクスチャにしてしまう」 という単純な方法で良ければ、対応は容易です。 今回の実装にはそれも用意しておきました。

f:id:hirasho0:20190218134116p:plain

左がRGBA32の元画像、 中央は、元画像のRGBをYUV化し、Aはそのまま別テクスチャに分離して、 シェーダでこれらを合成したものです。 右は各4bit(ARGB16)に減色した画像です。

「単純な16bit化は許容できないが、RGBA32そのままでは容量が辛い」 というケースでは使える局面もあるかと思います。

まとめ

YUV化は画素あたり12bitと、中途半端な圧縮率です。 インポータもコンポーネントもシェーダも専用であり、 インデクスカラーの時と同様の面倒くささがあります。

しかしインデクスカラーに比べると、以下の点で優れます。

  • バイリニアが重くならない
  • バイリニア用のシェーダが必要ない
  • 品質が減色に使うソフトの出来に左右されない

インデクスカラーでは、バイリニアはシェーダで自前で やらねばなりませんでした。 しかし、今回は単にテクスチャのバイリニアフィルタ設定を有効にするだけで バイリニアがかかります。バイリニアのために別のシェーダが必要になることもありません。

そして、品質はおおよそ良好で、特に弱点を攻めるような画像でない限り、 気にせずに使えます。特別なソフトも不要であり、 インポータに任せるだけでもそれなりな質になります。

圧縮率はRGB24bitに比べれば半分にしかなりませんが、 大抵のGPUは24bitテクスチャをメモリ内で32bitにふくらませますので、 メモリ消費で考えれば3/8になり、そう悪くはありません。 また、アルファチャネルが必要であれば、足すこともできます。

以上から、「品質で悩みたくはないが、減らないよりは減る方がよく、 使いどころがはっきりしていて別コンポーネントでも許せる用途」 が向く、ということになります。

まあ、ETC2で良くない? と思いますよね?

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に収まりますよ。 何か面白い使い途が見つかると良いのですが。