KLabGames Tech Blog

KLabは、多くのスマートフォン向けゲームを開発・提供しています。 スマートフォン向けゲームの開発と運用は、Webソーシャルゲームと比べて格段に複雑で、またコンシューマゲーム開発とも異なったノウハウが求められる領域もあります。 このブログでは、KLabのゲーム開発・運用の中で培われた様々な技術や挑戦とそのノウハウについて、広く紹介していきます。

〜エンジニア不要で納品をまわす仕組み〜

このエントリーは、KLab Advent Calendar 2016 の12/24の記事です。

KLabのとある新規アプリ開発プロジェクトでは、アプリ内で使用する制作物の納品フローを工夫して、エンジニアの手がどんどん不要になってきています。

現在稼働しているワークフローを紹介します。

納品フローに関わるプロジェクトチームは大きく5つの班に別れています。

  1. ゲーム内の各種パラメータを入力する「企画班」
  2. UIパーツを作成する「UI班」
  3. 演出を作成する「演出班」
  4. サウンドを作成する「サウンド班」
  5. プログラム作成の全般を担う「開発班」

以上の各班の作業内容がプロジェクトの最終成果物としてのipaやapkファイルに取り込まれるまでの流れです。

以下では単に「バイナリ」と書いた場合ipa、apkファイルのことを指します。

また、プロジェクトで使用しているGitレポジトリは

  • client-server ソースコード・パラメータTSV用
  • ui-effects 演出・UIアセット用
  • sound サウンド用

と分割されています(実際には細かいものがもっとあります)。

各レポジトリのメインブランチは、origin/masterを使用しており、バイナリ作成時に最終的に使われるブランチです。

そのため、いつでもバイナリ作成ができるようにメインブランチは常に正しく動作することが求められます。

workflow

ポイントは2つです。

  1. エンジニアの手を介さずにバイナリをビルドし動作確認ができる
  2. エンジニアの手を介さずにメインブランチが更新できる

1. エンジニアがいなくてもバイナリをビルドし動作確認ができる

1番目のポイントは、誰でもSlack上でBotに話しかけることで簡単にバイナリ作成が出来るので最新のメインブランチに自分の作業内容を取り込んでデバッグ用端末上ですぐに動作確認ができることです。

話しかける内容はこんな感じです。

@builder1.1
os=ios
branch=origin/master
ui-effects=origin/update-effect1
sound=origin/master
title=演出1更新確認
description=個人テスト用_演出1更新

自分が動作確認したいブランチとOSを指定してタイトル、説明文を書くだけです。

初めての人でも3分教えれば操作を覚えられます。

これが導入される前は、ビルドは週末のプロジェクトの正式ビルド時のみで、開発班以外にとって自分の作業内容がデバッグ用端末上で確認できるまで数日待つ必要がありました。

それが今では10分ほどに短縮されました。

また、メインブランチの状態が動作確認されるのも数日に1回程度だったので、メインブランチが正常に動作しない時にどこでバグが入りこんだのか調査するのに時間がかかっていました。

今では、30分に1回は誰かがビルドしているので、メインブランチに何かあればすぐに開発班以外からバグ報告が来るのでバグの発見速度があがっています。

さらに、Jenkins上のジョブは触っていけない項目も多く、開発以外には解放していないプロジェクトも多いかと思います。

そんな場合でも、SlackのBotでラップしてしまえば必要な項目だけをパラメータ化でき、自由度の高いバリデーションもかけられるので、安全に実行してもらうことができます。

仕組みの構築は1時間程度で出来るものなので、メリットが大きいです。

実装は簡単で、SlackのReal Time Messagin API を使用してBotを作成し社内のサーバーでデーモンとして起動しています。

このデーモンが、Slackからのメッセージを受取り、ビルドパラメータにパースして、社内のJenkinsビルドサーバーに対してパラメータとともにPOSTリクエストを投げます。

ビルドが完了するとEMランチャにアップロードされ、Slackチャンネルに通知が来る、という流れです。

2. エンジニアを介さずにメインブランチが更新できる

2つ目のポイントは、開発班以外でもエンジニアの手を介さずにメインブランチの更新ができることです。

※もちろん一部必要な箇所は手動で取り込みます。

先に記した通りメインブランチは常に正常に動作することが求められているので、不正な変更は取り除く必要があります。

それにもかかわらずなぜエンジニアの手を介さずに更新ができるのかを説明していきます。

開発班以外がメインブランチを更新する流れには大きく2つ存在しています。

  1. UI・サウンド班はSVNを更新し、cronジョブがメインブランチへ自動定期取り込み
  2. 企画・演出班はGitレポジトリのメインブランチに対してプルリクを投げる

2.1. UI・サウンド班

UI班が納品するのは主にテクスチャで、サウンド班はオーサリングツールで書き出したサウンドデータを納品します。

この2班はもともとSVNに慣れていてGitに移行するメリットがなかったのでそのままSVNを使ってもらっています。

cronジョブが必要に応じた頻度で走っていて、SVN側で更新があると納品物に対する自動テストが走り、テストにパスするとGitレポジトリの方にコミットされて取り込まれます。

そのため、Gitレポジトリの方で開発班がデータに触る段階ではすでに命名規則などの自動テストの項目をすべて満たした状態が保証されています。

UI・サウンド班がSVNにデータをコミットするだけで、開発が手をかけずともメインブランチが更新できてしまいます。

UIは図3のフローです。

サウンドに関しては少し特殊で、目で見て分からない分動作確認の必要性が高いので、後述の企画・演出班と同様にUnityとGitの環境を構築しています。

サウンドは図1のフローです。

以下の流れになっています。

  1. サウンドデータをSVNにコミット
  2. soundレポジトリのorigin/stgにcronジョブで取り込まれる
  3. ローカル開発環境にorigin/stgをチェックアウトしUnityEditorで再生し確認
  4. Slackからorigin/stgを指定してバイナリビルド
  5. デバッグ用端末上で動作確認
  6. origin/stgをメインブランチにマージ

バイナリファイルや、大量のファイルは人間の目で確認するよりも機械的にチェックしたほうが不正なデータが取り込まれる可能性が減らせます。

2.2. 企画・演出班

図2のフローです。

企画班が納品するのはゲーム内の各種パラメータが記述されたTSVで、演出班は演出のプレファブとそれが使用するすべてのデータ (.prefab, .png, .mat, .anim, .controller, .fbx)を納品します。

企画班、演出班ともに開発班と同じフローで作業しています。

全員UnityとGitをインストールしており、各自の開発環境を構築しています。

サーバーは開発と同様に個人用サーバーにそれぞれ接続しています。

加えて、開発班以外は共通でGithubDesktopをインストールしています。

GithubDesktopを選んだ理由は主に3つです。

  • MacとWindowsに対応している。
  • Gitの複雑な概念を知らなくても使えるように複雑さを上手く隠蔽している。
  • 必要であればGitシェルを立ち上げて任意のGitコマンドが実行できる。

GithubDesktop独自の概念は例えば以下のようなものです。

  • 「Sync」- ローカルとリモートのブランチの内容をより新しいコミットの方に同期すること。
  • 「Update from master」- ローカルのmasterブランチの内容を作業ブランチにマージすること。
  • 「Publish」- 自分のローカルの作業ブランチをリモートにPushすること。

ブランチ切り替えや、PullReqもGithubDesktop上から出せます。

コンフリクトを発生させないために次のことに注意してもらっています。

  • 自分の作業内容をチームに周知し、同じファイルを編集しない
  • 作業開始前に必ずSyncで自分の環境を最新化する
  • 自動テストをパスしたPullReqはすぐにマージする
  • 自分のPullReqがマージされたら周知してそれぞれの環境でSyncさせる

また、Github EnterpriseのTeam機能を使って班ごとのTeamを作成し、アクセス権をコントロールしています。

例えば、演出Teamにはui-effectsレポジトリのみ読み書き権限を与え、その他のレポジトリは読み取りのみとしています。

これにより大雑把にレポジトリを守ることができます。

3. まとめ

納品フローに関する説明は以上になります。

この仕組みを作り上げるまでは当然開発の工数がそれなりに必要でした。

また、導入した当初は特にGit周りに関する質問が多く、開発班によるサポートが必要でした。

しかし今ではごくたまに発生するコンフリクト解消とシステム側のトラブル以外ではほとんどサポートが必要なくなってきています。

現時点での感想としては、

  1. 開発サイクルを速くまわす必要がある新規開発において早期の納品の仕組み化はおおきな価値があること
  2. 同じ作業を誰でもできるようにすることは想像を超える効果を生み出すこと

を感じています。

UI班から開発班へなどのファイルの受け渡しに関して当初は、プロジェクトの発足からしばらくは手渡しやファイル共有サーバーを使用していました。

そのため、単なる画像の差し替えやサウンドの差し替えにもいちいち開発班の手を介す必要がありました。

これは今振り返れば、開発サイクルを速くまわす必要がある新規開発においては時間的に大きなロスだったと感じます。

次の新規案件では最初の納品の前に仕組みを構築しておきたいものです。

Slackからのビルドシステムを作った当初の目的は、ビルドサーバーのJenkinsにアクセスしてパラメータを入力するのが面倒だったのでSlack上での会話の流れでビルドできたら素敵だよねというちょっとした遊び心でした。

それが今となっては各班が自分たちで作業と確認のサイクルをまわすためのコアとなっていることは驚きです。

繰り返し作業を単に自動化することと、それをさらに普段使い慣れたインターフェイスからすぐにアクセスできるようにすること、の間には大きな隔たりがあります。

前者は自分が作業を繰り返す回数を量的に変えるのに対し、後者は作業自体を質的に変える可能性をもっています。

今後もプロジェクトのみんなが幸せになる納品フローを常に模索していきたいです。

このエントリーは、KLab Advent Calendar 2016 の12/22の記事です。

22番手も緊張しますね。KLabGamesの基盤エンジニアのkenseiです。よろしくお願いします。

プロローグ

ある日突然事は起こります。それまで順調にビルドされていたjenkinsのandroidビルドが突然のエラー。ログを見るとstderrの項目に

trouble writing output:
Too many field references: 131000; max is 65536.
You may try using --multi-dex option.

の文字が。そう、Androidはファイル内でコードによって呼び出すことができる参照の総数で65535を超える事ができないのです。

64K を超えるメソッドを使用するアプリの設定

いつかは戦わないといけないのですが、いざその日が来ると憂鬱ですね。ちなみに宗教上の理由でmulti dexはご法度です。

まずは計測

dexのメソッド数を計測してみます。ありがたいことにツールが公開されているので、それを使用します。

dex-method-counts

$ ./gradlew assemble
$ ./dex-method-counts path/to/target.apk > method-count.txt

吐かれたログを見ると

.
.
android: 16833
com: 19806
  facebook: 3263
  google: 14115
    android: 13911
    gms: 13911 <= gpgs
.
.

androidはしょうがないとして、うむ、、GPGSでかいな。。てことで、ここを重点的に潰す事にします。

aarとの戦い

最近のandroid libraryはAAR(Android Archive)で提供されています。ただのzipですが、jarは中に入ってしまってるので解凍しないといけません。という事でみんな大好き、shellの出番です。

本当はgradleでスマートにやりたかったのですが、aarをzipTreeするとpermissionエラーを吐いてgradleが死にました。googleさん、中をゴニョゴニョしたい人の事考えてassembleして下さい。。

準備

unzipするので、Assetsと同じフォルダにディレクトリを作ります。そこにshellを作っていきます。

$ mkdir -p Target/shrink-jar-task && touch shrink-jar.sh && chmod u+x shrink-jar.sh

まずはおまじない

#!/bin/bash

# 変数の準備
WORKSPACE=$(pwd)
WORK_DIR="${WORKSPACE}/tmp"
GRADLE_WORK_DIR="${WORKSPACE}/lib"
PLUGIN_DIR="${WORKSPACE}/../Assets/Plugins/Android"

# 優しさ
while getopts v OPT
do
  case $OPT in
    v ) DEBUG="true"
        ;;
  esac
done

# おしおき
if [ "${JAVA_HOME-undefined}" = "undefined" ]; then
    echo "JAVA_HOME not set"
    echo "alias emacs='vim'" >> .bash_profile
    exit 1
fi
if [ "${ANDROID_HOME-undefined}" = "undefined" ]; then
    echo "ANDROID_HOME not set"
    echo "alias emacs='vim'" >> .bash_profile
    exit 1
fi

# ワーク作る
if [ -d $WORK_DIR ]; then
  rm -rf $WORK_DIR
fi
mkdir $WORK_DIR
if [ -d $GRADLE_WORK_DIR ]; then
  rm -rf $GRADLE_WORK_DIR
fi
mkdir $GRADLE_WORK_DIR

だいたいコメントの通りです。途中でJAVA_HOMEとANDROID_HOMEを設定せずにビルドをしようとする不届き者にemacsを使えなくするお仕置きをしています。vimを使ってればお仕置きされずに済んだのに。。

準備その2

shellでやっていますが、一部の処理はgradleを使用するのでhomebrew等でインストールを済ませておいて下さい。あ、言うの遅くなりましたが、動作はmacでしか確認していません。

上記で用意したフォルダにbuild.gradleファイルを作成し、下記の内容を設定します。

$ vim build.gradle
task wrapper(type: Wrapper) {
    gradleVersion = '2.14.1'
}

設定後にbuild.gradleファイルのある場所で

$ gradle wrapper

を実行します。

aarを解凍

Assets/Plugins/Androidの下にあるaarを解凍していきます。

echo "unziping..."
find $PLUGIN_DIR -type f -name "*.aar" -exec basename {} \; | sed -e "s/.aar//" | xargs -I{} unzip ${PLUGIN_DIR}/{}.aar -d ${WORK_DIR}/{} >/dev/null 2>&1
find $WORK_DIR -type d -maxdepth 1 -mindepth 1 -exec basename {} \; | xargs -I{} rm -rf ${PLUGIN_DIR}/{}

ちなみにmaxdepthオプション付けてディレクトリをfindすると、rootが入って来てしまうのですが、mindepthを重ねがけするとrootを除外できるのを今回発見しました。他のスマートなやり方あるかもしれないので、もっと綺麗なshellを書けるようになりたい。。

aarを削除

もう使用しないので、aarを消していきます。

echo "remove plugin aar..."
if [ "$DEBUG" == "true" ] ; then
    find $PLUGIN_DIR -type f -name "*.aar*" -not -name 'appcompat-v7*.aar' -not -name 'support-v4-*.aar' -exec echo "  "{} \;
fi
find $PLUGIN_DIR -type f -name "*.aar*" -not -name 'appcompat-v7*.aar' -not -name 'support-v4-*.aar' -exec rm {} \;

この時にappcompatとsupportはいったん除きます。理由は後で面倒くさいからです!

classes.jarを取り出す

解凍したaarの中からjarを取り出していきます。

echo "move classes.jar"
find $WORK_DIR -type d -maxdepth 1 -mindepth 1 -not -name 'appcompat-v7*' -not -name 'support-v4-*' -exec basename {} \; | xargs -I{} mv ${WORK_DIR}/{}/classes.jar ${GRADLE_WORK_DIR}/{}.jar
find $PLUGIN_DIR -type d -name "firebase*" -exec basename {} \; | xargs -I{} mv ${PLUGIN_DIR}/{}/libs/classes.jar ${GRADLE_WORK_DIR}/{}.jar
if [ "$DEBUG" == "true" ] ; then
  find $GRADLE_WORK_DIR -type f -name "*.jar"  -exec echo "  "{} \;
fi

ファイルが同名なので、元のフォルダ名+"jar"に名前を変更しています。また、AARファイルになっていないAndroidLibraryのjarも持ってきています。今回はfirebaseを指定しています。

classes.jarを一つのjarにまとめる

renameしたlibフォルダのjarファイル達をunzipして一つのjarファイルにまとめてしまいます。build.gradleを編集します。

apply plugin: 'java'

dependencies {
    compile fileTree(dir: 'lib', include: ['*.jar'])
}

jar {
    from configurations.compile.collect { it.isDirectory() ? it : zipTree(it) }
    archiveName = 'google.jar'
}

task wrapper(type: Wrapper) {
    gradleVersion = '2.14.1'
}

shellにgradleのjarタスクを実行させます。

echo -n "make aggregation jar"
./gradlew -b build.gradle jar

-b でgradle 設定ファイルを指定しています。

full-gpgs-jar

jd-guiで生成されたjarを確認した所 一個のjarファイルにまとまりました。

proguard ruleを生成

使用していないメソッドをstripするためにproguardをかけていきます。一からproguardのruleファイルを書いていくのは面倒なので、aarの中に入ってるproguard.txtを再利用します。また、アプリ独自の設定を書いていくようにproguard-rules.proファイルを用意します。

$ touch proguard.base

shellにproguard ruleファイルを生成させます。

echo "generate proguard rule"
cat $ANDROID_HOME/tools/proguard/proguard-android.txt > proguard-rules.pro
find $WORK_DIR -type f -name "proguard.txt" | grep -v appcompat-v7 | grep -v support-v4 | xargs -I{} cat {} >> proguard-rules.pro
cat proguard.base >> proguard-rules.pro

ちなみにデフォルトのproguard-android.txtもまとめて1ファイルにしてしまってます。gradleのproguardタスクでgetDefaultProguardFile関数が使えなかったので。。

jarファイルダイエット

proguardを使用して、未使用のメソッドをjarから抹殺します。androidのpluginとjavaのpuluginでタスクがかち合うので、別名のgradle設定ファイルにします。今回はproguard.gradleとします。

まずはandroidプラグインを使ってビルドを通すためにダミーのAndroidManifest.xmlを作成します。

$ mkdir -p src/main && vim src/main/AndroidManifest.xml
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android" package="com.klab.tekitou" >
    <uses-sdk android:minSdkVersion="7" />
</manifest>

次にgradle設定ファイルを作成します。

$ vim proguard.gradle
buildscript {
    repositories {
        jcenter()
    }
    dependencies {
        classpath 'com.android.tools.build:gradle:2.1.0'
    }
}
allprojects {
    repositories {
        jcenter()
    }
}

apply plugin: 'com.android.library'

android {
    compileSdkVersion 23
    buildToolsVersion "23.0.3"

    defaultConfig {
        minSdkVersion 14
        targetSdkVersion 23
    }
}

task clearJar(type: Delete) {
    delete '../Assets/Plugins/Android/google.jar'
}
task proguard(type: proguard.gradle.ProGuardTask) {
    configuration 'proguard-rules.pro'
    injars 'build/libs/google.jar'
    outjars '../Assets/Plugins/Android/google.jar'
    libraryjars System.getenv("JAVA_HOME") + '/lib/rt.jar'
    libraryjars System.getenv("JAVA_HOME") + '/lib/jce.jar'
    libraryjars System.getenv("ANDROID_HOME") + '/platforms/android-23/android.jar'
    libraryjars System.getenv("ANDROID_HOME") + '/extras/android/m2repository/com/android/support/support-annotations/23.4.0/support-annotations-23.4.0.jar'
    libraryjars 'tmp/appcompat-v7-23.1.1/classes.jar'
    libraryjars 'tmp/support-v4-24.0.0/classes.jar'
    libraryjars '../Assets/Plugins/Android/firebase-common-9.8.0/libs/classes.jar'
}
proguard.dependsOn(clearJar)

injarsにclasses.jarをまとめたgoogle.jar、libraryjarsにjarが依存している物を指定しています。

※適当に必要そうなのを足していったので、結局どれが本当に必要なのか分かってない

appcompatとsupportは最初に作業フォルダに持ってきたけど、excludeしていたjarを使用します。support-annotationsはversionがpathに入っていますが、target sdk versionの中で一番新しいのを適当に設定しています。android.jarはproguard.gradleで指定したcompileSdkVersionを指定しています。

shellにgradleのjarタスクを実行させます。

echo "shurink jar"
./gradlew -b proguard.gradle proguard

ここはビルドに必要なlibraryjars不足によるエラーとの戦いになるので、何回もgradlewコマンドを叩いて通るまで頑張ります。

minimam-gpgs-jar

できあがったミニマムなjarファイルです。

aarの中のresourceを戻す

解凍したaarの中のリソースを戻していきます。Assets/Plugins/Androidフォルダ直下に置いて、packagingをUnityに任せます。

echo "copy unziped android resources"
if [ "$DEBUG" == "true" ] ; then
  find $WORK_DIR -type d -maxdepth 1 -mindepth 1 -exec basename {} \; | grep -v appcompat-v7 | grep -v support-v4 | xargs -I{} echo "  "${WORK_DIR}/{}
fi
cat <<EOS > project.properties
target=android-14
android.library=true
generated=true
EOS
find $WORK_DIR -type d -maxdepth 1 -mindepth 1 -exec basename {} \; | grep -v appcompat-v7 | grep -v support-v4 | xargs -I{} cp project.properties ${WORK_DIR}/{}/
find $WORK_DIR -type d -maxdepth 1 -mindepth 1 -exec basename {} \; | grep -v appcompat-v7 | grep -v support-v4 | xargs -I{} cp -r ${WORK_DIR}/{} ${PLUGIN_DIR}/{}
if [ "$DEBUG" == "true" ] ; then
  find $WORK_DIR -type d -maxdepth 1 -mindepth 1 -exec basename {} \; | grep -v appcompat-v7 | grep -v support-v4 | xargs -I{} echo "  "${PLUGIN_DIR}/{}
fi

project.propertiesを追加しないとUnityにAndroidResourceとして認識されないので、追加しています。targetはPlayer SettingsのMinimum API Levelを設定すれば大丈夫だと思います。generated=trueとしてるのは、aarのバージョンアップ時にフォルダを消すためのマーキングです。

きちんとAndroidResourceとして認識されているかはUnityAppPath/Temp/StagingArea/android-librariesを見ると確認できます。

proguardファイルの設定

上記で生成したjarファイルはミニマム過ぎて動きません。試しにfirebaseを全部stripから覗いてみます。

$ vim proguard.base
-dontwarn android.support.v4.app.**
-dontwarn com.google.firebase.**
-dontwarn com.google.android.gms.**

-keep public class com.google.firebase.**

ついでにwarningがうるさいので切っています。

  • aarはresourceが未コンパイルなので、R.classがないエラーが出るので。
  • R.$のエラーだけ出るようになるまではdontwarnは設定しない

実行してみると

add-firebase

firebaseが復活しているのが分かります。

ここからはUnityでAndroidビルド&&実機で動かしながら動作確認 => 落ちた所でログを確認して、必要なクラスをproguard.baseに足してshellを流す の作業の繰り返しになります。

エピローグ

vimを使いましょう

明日はTech Blogなのに、泣かせるので評判な@hhattoです。

このエントリーは、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に望みを託す、というのが今はよいかもしれません。

以上です。

↑このページのトップヘ