輝度+色差でテクスチャ圧縮(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で良くない? と思いますよね?