このエントリーは、KLab Advent Calendar 2016 の12/19の記事です。 やまだです。 昨年はWebGLをつかったソートを実装してみましたが今年はWebGLを使った画像圧縮を実装してみます。

はじめに

WebGLのみならずアプリ開発において画像のフォーマットの選択は重要です。 とくにゲームの大部分を占める画像データはゲームのダウンロード時間に直結するため可能な限り圧縮しておきたいものです。

WebGLではGPUにテクスチャをアップロードする際にはgl.texImage2Dを使用します。 gl.texImage2DTexImageSourceをアップロードできます。 このTexImageSourceというのが面白くてテクスチャデータとしてHTMLImageElementが使えます。 すなわちGIF/JPEG/PNGといったフォーマットだけでなくGoogleが開発した高圧縮率フォーマットであるWebPも使用可能です。 さらにはHTMLVideoElementもテクスチャフォーマットとして使用できるため、動画さえもテクスチャにできます。 ダウンロードサイズの削減が目的であればGIF/JPEG/PNG/WEBPから適切なフォーマットを選ぶのがよいでしょう。

一方で今日ではモバイルデバイスもターゲットとなってくるため、 ビデオメモリの容量も気にしたいところで、画素あたりのビット数も削減したいところです。 WebGLにおいてはPNGなどを用いた場合、1画素あたり32bit使用することが多いでしょう。 その場合、1024x1024のテクスチャ1枚当たり4MByteのサイズとなります。 そうなってくると選ばれるフォーマットはETCやPVRTCでしょう。 例えばETC2の8bit/pixelを用いれば1024x1024のテクスチャも1/4のサイズである1MByteまで削減できます。 ですが、これらはWebGLでも使用可能ですがWebGL 1.0ではエクステンションのため使用できる保証がありません。 ETC2はWebGL 2.0で使用可能とのことなので今後はETC2を積極的に使用していきたいところです。 ETCやPNG等を使用するのも手ではありますが前述のとおりいくつかの無視できない課題が存在するため、 今回はこれらとは異なるアプローチで画像圧縮を試みてみました。

YCgCo色空間による圧縮

Unity向けではありますがこちらYCgCo色空間を使用して画像の圧縮を試みている方がいらっしゃいました。 記事で解説されている通りではありますが画像をLuma(明度)とChroma(彩度)に分割し、 人間の目はLumaに比べてChromaの変化に対して鈍感である性質を利用してChromaの情報量を落として圧縮しています。

元画像

lena

Lumaのみの画像

luma_only

Chromaのみの画像

chroma_only

人間の目はLumaのみの画像に対してChromaの変化に鈍感であることが実感できると思います。 このアイディアそのままにWebGLによる再実装を行いました。

色空間を変換するシェーダ

RGBAをYCgCoAに変換する

YCgCoとRGBの色空間は行列による変換が可能です。なのでシェーダとも相性がよくシェーダでも行列を使用して変換を行います。 1つだけ注意しないといけないところはYCgCoのYの値域は[0, 1]に対してCgCoの値域は[-0.5, +0.5]のためテクスチャに保存する際に0.5を加算します。 この加算含めて4x4行列にまとめるとRGBAをYCgCoAに変換するシェーダは以下のように書けます。

元となる実装と同じようにYをAlpha8に割り当て、CgCoAをRGB565のフォーマットに書き込むことを考えて swizzleを使用して(Y,Cg,Co,A) => (A,R,B,G)のように割り当てます。 さらに変換後の画像をさらにRGBとAに分割し、RGB画像の解像度を1/4に縮小します。 結果1画素あたりのビット深度はAlpha8(8bit) + RGB565(16bit) / 4 = 12bitとなります。

precision mediump float;

uniform sampler2D u_map;

varying vec2 v_uv;

void main(void) {
    mat4 m = mat4(
        0.25, -0.25, 0.5, 0.0,
        0.5, 0.5, 0.0, 0.0,
        0.25, -0.25, -0.5, 0.0,
        0.0, 0.5, 0.5, 1.0
    );

    vec4 c = texture2D(u_map, v_uv);
    vec3 ycc = (m * vec4(c.rgb, 1.0)).xyz;
    float alpha = c.a;
    vec4 ycca = vec4(ycc, alpha);

    gl_FragColor = ycca.ywzx;
}

Yのみのアルファ画像

lena_y

縮小したCgCoA画像

lena_cca

YCgCoAをRGBAに変換する

YCgCoAに変換した2枚のテクスチャをシェーダで合成し、RGBAに変換します。 その際にVRAMの節約のためにCgCoA画像はRGB565のテクスチャで、Y画像はAlpha8のテクスチャとしてアップロードします。

// Y画像
gl.texImage2D(gl.TEXTURE_2D, 0, gl.ALPHA, gl.ALPHA, gl.UNSIGNED_BYTE, image);
// CgCoA画像
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGB, gl.RGB, gl.UNSIGNED_SHORT_5_6_5, image);

シェーダで合成する際はCgCoに0.5を加算しているため行列をかけるまえに0.5ひいてやります。

precision mediump float;

uniform sampler2D u_tex0; // CgCoA画像
uniform sampler2D u_tex1; // Y画像

varying vec2 v_uv;

void main(void) {
    mat3 m = mat3(
        1, 1, 1,
        -1, 1, -1,
        1, 0, -1
    );
    vec3 cca = texture2D(u_tex0, v_uv).xyz;
    float y = texture2D(u_tex1, v_uv).w;
    vec4 c = vec4(y, cca.x, cca.z, cca.y) - vec4(0.0, 0.5, 0.5, 0.0);
    gl_FragColor = vec4(m * c.xyz, c.a);
}

合成結果

result

比較

左:元画像、右:合成画像

compare

ほぼ劣化がないように見えます。 念のためちゃんと画像が劣化しているのかを確認するために こちらを参考に差分をとってみます。

$ composite -compose difference lena.png result.png diff.png
$ identify -format "%[mean]" diff.png
428.525

確かに差分が発生しているので画像は劣化してはいるようです。 差分画像を正規化した物がこちらです。

diff_normalized

全体的に差分が発生していることが確認できます。 Chromaのビット数を削減しているので納得できる結果となりました。

最後に

YCgCoによる圧縮は見た目上は効果的であるということはわかりましたが、いくつかの問題点があります。

1つはテクスチャフェッチが2回必要となること。 テクスチャフェッチは重い処理なのでできれば少なくしたいところです。

もう1つはアルファのみを保存する適切なフォーマットが見つからないこと。 Y画像は8bit深度のアルファ画像なのですがPNG画像でアルファ付き画像として保存すると、 32bit深度で保存されてしまい少しもったいないです。

また、原因の特定はできなかったのですがGPUによってはうまく描画できない例も見受けられました。

得られる画像は非常に良いものではあるのですが、現状のWebGLにおいてでは画像の圧縮はダウンロードサイズの削減が目的であればPNG/JPEG/WebPを使用し、 ビデオメモリの節約であればETC2に望みを託す、というのが今はよいかもしれません。

以上です。