インデクスカラーテクスチャの思い出 〜懐かしのパレット〜

f:id:hirasho0:20190121065708g:plain

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

この記事では、今更インデクスカラーを実装してみたことについて書きます。 テクスチャ圧縮について調べた時に SEGA TECH BLOGの記事 を見つけまして、自分でも試したくなったのです。

なお、結論を先に申しますと、「ETC2で良くね?」となります。 この先は趣味だと思っていただいて結構です。

歴史

昔、画像の形式に「インデクスカラー(Indexed Color)」というものがありました。 まともな圧縮アルゴリズムがない時代、 全部の画素に32bit使うのを回避するために、 「0番は#ffee88で、1番は#8530eeで...」 といった具合の表(Color Lookup Table略してCLUT、あるいは「パレット」)を用意し、 各画素には色の番号だけを入れる方式です。 表が256要素であれば各画素8bit(1/4) 表が16要素であれば各画素4bit(1/8)、 表がもし2要素しかなければ各画素1bit(1/32)にできます。 年寄の昔話のようで恐縮ですが、 私が新人だった頃(2002)は「16色パレット以外実質使い物にならない」という、 当時ですら辛い機械が覇権を握っておりまして、いろんなものが16色で描かれておりました。

しかしその後、多くの機械ではDXT(DirectX Texture Compression。S3TCと内容は同じ)が普通に使えるようになり、 大抵の画像では無理に色を減らしてパレットを使うよりも、 DXTを使う方が綺麗で、しかも速い、ということになりました。 DXT1(別名BC1)は各画素4bitで半透明のない画像がそこそこ綺麗に表現できますし、 DXT5(別名BC3)は各画素8bitで半透明がある画像であってもそこそこ綺麗に表現できました。

さらに時は下ってスマホ時代になり、PVRTC、ETC、ASTCといったDXTよりも新しい形式が現れ、 GLES3.0ではETC2のサポートが必須、GLES3.2ではASTCのサポートが必須、となりました。 新しい形式ほど高度であり、大抵の場合より小さな容量で、より綺麗な画質を実現できます。

そういうわけで、GLES3.0が動かない機械を無視して良ければ、 「とりあえずETC2で圧縮して、汚いなら別途考える」 というのが妥当なやり方と言えるでしょう。 単なるETC2は不透明な画像を画素あたり4bitで表現し、 ETC2+EACであれば半透明であっても画素あたり8bitで表現できます。

パレットを使う利点

そんな時代に今更パレットなんて役立たずなわけですが、 パレットには若干の利点もあります。

  • ブロックノイズが出ない
  • 圧縮が速い
  • 圧縮後の状態がUnityに入れる前にわかる
  • GLES2しかサポートしてなくても使える

一つづつ見ていきましょう。

ブロックノイズが出ない

DXT、ETC、ASTC、PVRTC、といった形式は、 1画素づつ情報を格納せず、ある程度の単位で圧縮を行っています。 例えばDXTであれば、4x4の16画素についてパレットを2色用意し、 16画素分「パレット2色の混合比」を記録します。 混合比は4通りなので2bitです。

したがって、4x4ブロックの中に入れられる色数はたった4色で、 しかもうち2色は補間です。「赤と青と緑が入ってる」 というような時でも、そのうち二色しか入りません。 その結果、4x4ブロック境界ごとに汚ないノイズが出るわけです。 これをブロックノイズと言います。新しい形式ほどこれが目立たないように 工夫が凝らされています。

一方パレットの場合、ブロック単位の処理をしないので、 ブロックノイズは出ません。

圧縮が速い

普通、新しい圧縮形式ほど凝っているので圧縮には時間がかかります。 大きな画像になると分単位でかかることも珍しくなく、 並列化したり、GPUを使ったりしないとやってられません。 しかし「自前で圧縮してそのままUnityに入れる」 という方法がないのであればUnityに任せる以外なく、 Unityがどれくらいの時間で圧縮してくれるか次第になります。

一方パレットの場合、減色が済んだ後の処理は簡単です。 色に番号を振って、全ての画素について色を番号で置き替えるだけで、 今回試作したインポータでは2048x2048を処理しても1.5秒程度で終わります。 C#で書かずC++等で書いたツールを通せば、もっと速くできるでしょう。

圧縮後の状態がUnityに入れる前にわかる

私が知る限り、Unityは圧縮を済ませてからインポートすることができません。 圧縮はインポート時に行われます。 アーティストがUnityを使っていればその時点で圧縮の結果がわかりますが、 その時のプラットフォームだけです。 switch platformしない限り他の機種でどうなるかはわかりません。 インポート時には圧縮しないでビルド時まで保留する設定にしていたり、 アーティストがUnityを使っていない現場もあるでしょう。 ビルドして見るまで劣化の具合がわからない ケースが多いのではないかと思います。

画像の劣化具合もコントロールしたい、というアーティストにとっては腹立たしいでしょうし、 そもそもアーティストが「圧縮による劣化」を知らないまま になることだってあります。 「実機で見るとiOSだけ妙にこのボタンがモヤモヤしてるんだけど何?バグ?」みたいな。

一方パレットの場合、 減色を行うのはアーティスト側のPhotoshopなりOPTPiXなりですので、 Unityに入れる前の段階でわかります。

GLES2しか動かない機械でも動く

今回の実装では、テクスチャの形式は見掛け上Alpha8になります。 これは全機種でサポートされている形式であり、 OpenGLESのバージョンやらMetalのバージョンやらを気にする必要はありません。 かなり古い機械でも動くと思われます。

パレットの欠点

一方、パレットにはどうしようもない欠点があります。 この欠点が問題にならない用途でしか使えないわけです。

  • 遅い
  • 面倒くさい
  • 得意な画像が限られるし、減色にどのツールを使うかで全然違う

遅い

ETC2などの形式は、ハードウェアで展開されます。無圧縮のRGBA32 より遅くなることはありません。容量が少ない分だけ転送が速くなり、 普通は無圧縮よりも速くなります。

パレットを無理矢理実現する場合、ハードウェアのサポートはないので シェーダでやるしかありません。一度目のテクスチャ参照で色番号を得て、 二度目のテクスチャ参照で番号から実際の色に変換します。 2回読むので当然遅くなります。

また、以前「フラグメントシェーダ実行開始時にテクスチャ座標が決まっていないと泣くほど遅い」 機械を見たことがあります。もし今でもそういう機械があれば、予想外に遅くなることでしょう。 一度目のテクスチャ参照で得た色番号をテクスチャ座標として、二度目のテクスチャ参照を 行いますので、一度目が終わるまでテクスチャ座標がわからないのです。

そして最悪なことに、ハードウェアが持っているバイリニア補間が使えません。 普通であれば、4画素の中間を指定されれば、4画素を混ぜた色を返してくれます。 これはETCやASTCであってもそうです。 しかし、パレットの場合、色番号を混ぜてもらっても困ります。 4画素がそれぞれ0,1,2,3番であった場合、0番の色、1番の色、2番の色、3番の色を 混ぜねばなりませんが、ハードウェアのバイリニア補間に任せると(0+1+2+3)/4で1.5番の 色を読めと言うことになります。そんな色はありません。 実質バイリニア補間がない状態では使い物になりませんから、 バイリニア補間をシェーダで書く羽目になるわけですが、 長いし遅いし書くのは面倒くさいしで、いいことが何もありません。

おまけにもう一つ、Unityの場合、マテリアルの数だけ必ずDrawCallが分かれます。 UnityEngine.UI.Graphicを継承した専用のコンポーネントを用意するわけですが、 それぞれに違うテクスチャを差せば、それぞれが違うマテリアルになります。 パレット画像はアトラス化できませんから、何をどうやってもコンポーネントの数だけ DrawCallが発生します。CPUにもGPUにも優しくないわけです。

面倒くさい

ETCなどの圧縮形式であれば、インスペクターでインポート設定を 変えればそのようにインポートできます。 そして、圧縮形式であろうがなかろうが、普通のUnityEngine.UI.Imageで描画できます。

しかし、パレットはUnityがサポートする形式ではありません。 インスペクターでの設定はできず、 必ずインポーターのスクリプトを書く必要があります。 そして、色番号テクスチャと、色そのもののパレットテクスチャの2つのテクスチャを使い、 専用のシェーダで描画する以上、使う側のコンポーネントも別物になります。

今回の実装ではインポートはある程度自動化しており、 「CompressToIndex16」あるいは「CompressToIndex256」というフォルダ名が パスに含まれていればパレット画像としてインポートしますが、 コンポーネントは専用ですし、Inspectorには画像を2枚指定せねばなりまねん。 そして、もし後で「パレットをやめた」となれば、2つに分かれたテクスチャをAssetsから消し、 コンポーネントも取り替えることになります。面倒くさいのです。

得意な画像が限られ、結果がツール依存

色数が少なければいいのですが、色数が多い画像だとどうしても色が足りなくなります。 加えて、半透明な部分があると色数が大きく増しますので、なおさら不利になります。

そして、減色をどのツールで行うかで結果が全然違います。

f:id:hirasho0:20190121065656j:plain

左上が元画像、右上がOPTPiXによる減色、左下がImageMagickによる減色、右下がGIMPによる減色です。 OPTPiXだと結構綺麗に見えますが、ImageMagickはだいぶ落ちますし、GIMPは論外です (GIMPはたぶんアルファチャネル込みでパレットを作ってません)。

まとめれば、パレット化が有利な画像とは 「色が少なく、ブロックノイズが許せない」画像です。 そして、「ブロックノイズが許せない」画像とは、大抵の場合輪郭がくっきりした画像です。 例えば、「紫から白にかけてのグラデーションの色しかなく、高解像度の曲線が描かれている」 「やけに高解像度な線画主体の白黒画像」あたりは 可能性があるようには思えます。加えて賢い減色ソフトを使うことが前提になります。 たまたまベータ版の試用が無料だったのでOPTPiXを試せましたが、 もし試さなかったらこの記事を書くことすらなく諦めていたかもしれません。

なお、ImageMagickによる減色は以下のコマンドで行えます。

convert hoge_in.png -dither FloydSteinberg -colors 256 hoge_out.png

オプションを調整すればもう少し良くなるかもしれません。 ImageMagickはたまに便利ですので、選択肢として持っておくと良いかと思います。

実装

さて、すでにして使う気がほぼほぼ失せましたが、実装してしまいましたので ご覧に入れましょう。 動くものをWebGLでビルドして置いておきました。 ソースコードはこちらです。

f:id:hirasho0:20190121065726j:plain

実行するとこんな感じになります。上3枚が256色パレット化したもので、 下3枚が透明部分を黒くした後に白黒化し、16色パレット化したものです。 普通に色があるものを16色化しても使い物になるとは思えませんので、 「もしかしたら用途がありうるかも?!」と思える白黒に限って試すことにしました。 例えばモノクロの漫画風画像とかならアリかもしれません。

上下共に、左はパレット化する前の元画像(減色はされている)をUnityEngine.UI.Imageに 貼ったもの、中央と右はパレット化したものです。 中央と右の差はバイリニア補間の有無です 中央は補間ありなので、ある程度は拡大縮小に耐えます。 右は補間なしのため、拡大縮小するとエイリアシングノイズが出ます。 「絶対にピクセルパーフェクト(拡大も縮小もしない)で表示する」 という強い覚悟がない限り、補間ありでないと使い物にならないでしょう。 スマホの解像度は多様であり、解像度を固定することは不可能だからです。

構成

今回作ったものは以下の構成を持っています。

あとは、サンプルのメインスクリプトであるSample.cs と、そのためのプレハブ類、画像類が入っています。これらは導入時には不要です。

インポータ

インポータはUnityEditor.AssetPostprocessor を継承しており、パスに特定の文字列を含んでいる画像ファイルを処理対象とします。 個別にInspectorで画像のインポート設定をいじって回るのは面倒くさいしミスの元なので、 弊社東京プリズンではパスによって圧縮種別を変えていました。今回もその流儀で作っていますが、 これは製品個別の事情を鑑みて整備することになるでしょう。

PreProcess

まず、処理対象であれば、OnPreprocessTexture()にて、

importer.alphaIsTransparency = false; // 勝手にいじられるのを避ける
importer.isReadable = true; // 読めないと何もできない
importer.textureCompression = TextureImporterCompression.Uncompressed;
importer.mipmapEnabled = false; // ミップマップ禁止(不可能ではないだろうが、とりあえず)

以上の処理を行います。alphaIsTransparencyがtrueだと画像が改変されてしまって厄介なのでfalseにし、 isReadableをtrueにして画素を読み出せるようにします。 mipmapつきのものを作ることも可能だとは思いますが、簡単ではありません。 今回は面倒なのでナシにしています。

また、インポート対象がすでに処理した番号テクスチャ(index)である場合、 形式をAlpha8にして容量を1/4に削減します。

var settings = new TextureImporterPlatformSettings();
settings.format = TextureImporterFormat.Alpha8;

後はこうして作ったsettingsをStandalone、Android、iOS、WebGLの4プラットホーム向けに コピーしつつ設定します。 Standaloneビルドはデバグや計測に便利ですし、 WebGLビルドはこういった小さいサンプルでは体験してもらうのに便利ですから、 配慮しておいて損はないかと思います。

PostProcess

次に変換本体です。インポートが終わった後に処理するので、 OnPostprocessTexture()を実装します。例えば256色であれば以下のようになります。

bool TryCompressToIndexed256(Texture2D texture, string path)
{
    // 色辞書を生成
    var srcPixels = texture.GetPixels32();
    var map = MakeColorMap(srcPixels, 256);
    if (map == null)
    {
        return false;
    }
    var tableTexture = MakeTableTexture(map, 256);
    var indexTexture = MakeIndexTexture256(map, srcPixels, texture.width, texture.height);

    var lastPeriodPos = path.LastIndexOf('.');
    var outPathTrunk = path.Substring(0, lastPeriodPos); // ピリオド以下を削除
    Save(tableTexture, outPathTrunk + "_table256.png");
    Save(indexTexture, outPathTrunk + "_index256.png");
    return true;
}
  • Texture2D.GetPixels32() で画素配列を得る。
  • 色を数え、どの色が何番かの対応表を作る(MakeColorMap())
  • パレットテクスチャを生成する(MakeTableTexture())
  • 番号テクスチャを生成する(MakeIndexTexture256())
  • ファイル名を決めて書き出す

という順序です。詳細は難しくないのでコードを読んでいただければ良いと思うのですが、 若干の工夫をした点があるので簡単に述べます。

まず色と番号の対応表を作る処理ですが、標準のDictionaryを使うと遅いので、 開番地法によるハッシュテーブルを自作して高速化しています。 標準では2048x2048の生成に9秒ほどかかっていましたが、自作したところ1.5秒ほどになりました。 削除が不要で、キーがintで、限界の個数に当たりがつけられる場合、 100行未満でハッシュテーブルが実装できますので、 速度にお悩みの方は試してみると良いかと思います。

パレットテクスチャは幅が256、高さ1の細長いRGBA32テクスチャとして生成します。 単に色を番号順に並べるだけです。

番号テクスチャは各画素を番号に置き替えてアルファチャネルに書きこむだけですが、 SetPixels32()は 各色8bitのARGB32やRGBA32にしか使えないため、SetPixelを画素数分だけ呼びます。

これらの結果が以下です。

f:id:hirasho0:20190121065722p:plainf:id:hirasho0:20190121065716p:plainf:id:hirasho0:20190121065719p:plain

左が元画像、中央が番号をアルファに書きこんだ番号テクスチャ、右がパレットです。 合計容量は273.1KBから68.3KB+1KBに削減されました。

16色について

最後に16色パレットテクスチャについてですが、 Alpha4などという形式はないため、Alpha8の1画素に2画素を格納しています。

var composedIndex = (index0 << 4) | index1;

このために、解像度は幅が半分です。

f:id:hirasho0:20190121065702j:plainf:id:hirasho0:20190121065700j:plain

元の幅が奇数だと、全ピクセル完全に描画するのは面倒くさそうなので (そもそもできるのかどうかすらちゃんと考えてない)、 偶数にしておくのが無難かと思います。

専用コンポーネント

専用コンポーネントであるIndexedRawImageのInspectorはこんな感じです。

f:id:hirasho0:20190121065705p:plain

テクスチャ2枚を指定する場所があり、バイリニア補間の無効有効が設定でき、 あとは普通のUnityEngine.UI.Graphicと同様にRaycastTargetの有効無効、 そしてSetNativeSizeボタンがあります。

内部ではマテリアルを動的生成し、パレットテクスチャの幅が256か16か、そしてバイリニア補間が有効か によって使うシェーダを切り替えています。

Inspectorの見掛けをカスタマイズするためにUnityEditor.Editorを継承したクラスを用意して、 OnInspectorGUIを実装してあります。 Inspectorにテクスチャ等が設定された時にはシェーダが変わる可能性があるのでマテリアルを作り直すのですが、 自分でInspectorに足した項目の変更時にはOnValidateが来ないため、 OnInspectorGUI()にて自力で変更を検知する必要があります。例えばこういった具合です。

EditorGUILayout.BeginHorizontal();
GUILayout.Label("IndexTexture");
var newIndexTexture = (Texture2D)EditorGUILayout.ObjectField(self._indexTexture, typeof(Texture2D), false);
if (newIndexTexture != self._indexTexture) // カスタムエディタだと勝手にOnValidateが呼ばれない
{
    self._indexTexture = newIndexTexture;
    self.OnTextureChange();
    self.SetMaterialDirty();
}
EditorGUILayout.EndHorizontal();

面倒ですが仕方ありません。

シェーダの置き場所

動的にマテリアルをnewして、シェーダを設定する関係上、 シェーダをどこかに置いておかねばなりません。今回は、 事前にIndexedRawImageShaderHolderなるコンポーネントを どこかに置いておき、そのInspectorにシェーダを設定しています。

f:id:hirasho0:20190121065729p:plain

実に気持ち悪いですね。Assetsの下にシェーダがある以上、 Shader.Find()で 見つけてMaterialのコンストラクタ に渡してしまえばいいのですが、それだとエディタでしか動きません。 明示的な参照がないとビルドに入らず、「ビルドすると描画が紫になる!!」という事故が起きるのです。

これは東京プリズンの開発中にも起こり、非常に腹が立ちました。 ビルドに含めるシェーダを明に指定することもでき、 東京プリズンではそうしましたが、 できるだけエディタとビルドで同じところを通したいので、 今回は面倒ではありますが、実際にシーンに参照を持ったオブジェクトを置くことにしました。

Resourcesに入れておいてロードする、という手も考えられるかと思います。 その方が綺麗な気はしますが、Resourcesの下に物が増えていくと使っていない素材が溜まっていってひどいことになります(なりました)。 動的にパスを生成してロードされる可能性があるため、 「これは使っていない」ということを調べるのはなかなか大変なのです。 そういうこともあって、あまりResourcesの下にはいれたくないなという個人的な思いがあります。

シェーダ

シェーダは4種類あります。

  • 256色ポイントサンプリング
  • 256色バイリニアサンプリング
  • 16色ポイントサンプリング
  • 16色バイリニアサンプリング

一番基本的なのが、256色のポイントサンプリングです。

Shader "UI/Indexed256"
{
    Properties
    {
        _MainTex ("MainTexture", 2D) = "white" {}
        _TableTex ("TableTexture", 2D) = "white" {}
    }
    SubShader
    {
        Tags { "Queue"="Transparent" }
        LOD 100

        Pass
        {
            Blend SrcAlpha OneMinusSrcAlpha
            ZWrite Off

            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag

            #include "UnityCG.cginc"

            struct appdata
            {
                float4 vertex : POSITION;
                float2 uv : TEXCOORD0;
            };

            struct v2f
            {
                float2 uv : TEXCOORD0;
                float4 vertex : SV_POSITION;
            };

            sampler2D _MainTex;
            float4 _MainTex_ST;
            sampler2D _TableTex;

            v2f vert (appdata v)
            {
                v2f o;
                o.vertex = UnityObjectToClipPos(v.vertex);
                o.uv = TRANSFORM_TEX(v.uv, _MainTex);
                return o;
            }

            fixed4 frag (v2f i) : SV_Target
            {
                half index = tex2D(_MainTex, i.uv).a;
                fixed4 col = tex2D(_TableTex, index);
                return col;
            }
            ENDCG
        }
    }
}

マテリアルに設定する2枚のテクスチャを定義し、 SubShaderにてQueueをTransparentに設定、Passにてアルファブレンドの設定を行い、 あとはフラグメントシェーダの中身を書きます。

フラグメントシェーダは簡単でして、一回目のtex2Dで番号テクスチャ(_MainTex)を見て、 アルファに入っている色番号を取得し、 これをテクスチャ座標としてパレットテクスチャ(_TableTex)から色を得ます。 2行で終わりです。

バイリニア

バイリニアのシェーダは非常に面倒くさいです。フラグメントシェーダは以下のようになります。

// UV決定
half2 texcoord = i.uv * _MainTex_TexelSize.zw; // ピクセル単位座標に変換
texcoord -= 0.5; // i.uvはピクセル中心なので整数単位に直すと0.5がついている。これを減ずる
half2 texcoordFrac = frac(texcoord);
half2 uv00 = (texcoord - texcoordFrac + 0.5) * _MainTex_TexelSize.xy; //さっき減じた0.5を戻す
half2 uv11 = half2(
    uv00.x + _MainTex_TexelSize.x,
    uv00.y + _MainTex_TexelSize.y); // 1ピクセル分のUVを追加
half2 uv01 = half2(uv00.x, uv11.y);
half2 uv10 = half2(uv11.x, uv00.y);
// インデクス取得
half index00 = tex2D(_MainTex, uv00).a;
half index01 = tex2D(_MainTex, uv01).a;
half index10 = tex2D(_MainTex, uv10).a;
half index11 = tex2D(_MainTex, uv11).a;
// 色取得
fixed4 col00 = tex2D(_TableTex, index00.xx);
fixed4 col01 = tex2D(_TableTex, index01.xx);
fixed4 col10 = tex2D(_TableTex, index10.xx);
fixed4 col11 = tex2D(_TableTex, index11.xx);
// バイリニア
fixed4 col00_01 = lerp(col00, col01, texcoordFrac.y);
fixed4 col10_11 = lerp(col10, col11, texcoordFrac.y);
fixed4 col = lerp(col00_01, col10_11, texcoordFrac.x);
return col;

周囲4ピクセルの番号テクスチャを独立にtex2Dし、独立にパレットテクスチャからtex2Dした後、 3回のLerpで混ぜ合わせて自力でバイリニアフィルタをかけます。 詳細な説明は、それだけでブログが書けそうなので今回は省略します。

16色対応

16色パレットの場合には、1画素に2画素分の番号が入っているので、 これを取り出す必要があります。 新しい機械ではシェーダでも整数演算が行えるのでビット演算で簡単に抜き出せますが、 古い機械はfloatしか扱えません。例えばこんな感じです。

half indexEncoded = tex2D(_MainTex, i.uv).a * (255.01 / 16); // 整数部が左画素、少数部が右画素。.01は誤差対策。
half indexRight = frac(indexEncoded);
half indexLeft = (indexEncoded - indexRight) / 16;

0から1が入っているアルファに、まず255.01を乗じます。 なぜ255でないかと言えば、テクスチャの幅が239のように中途半端な時に、演算誤差が出るからです。 もし(1/239)*239の結果が、238.9999になれば、整数化した時には238になってしまいます。これを避けます。 そしてこれを16で割ります。すると、例えば239であれば、整数部に左画素の番号が、小数部に右画素の番号が入ります。 これをfracすれば右画素の番号が取り出せ、この値を元から引いて16で割れば、左画素の番号が取り出せます。

あとは、今塗ろうとしている画素が左画素か右画素かを判定します。

half texcoordX = i.uv.x * _MainTex_TexelSize.z; // ピクセル単位座標に変換(幅半分のテクスチャでの)
// 2倍して「元の幅」にし、これを0.5倍してfracが0.5なら右で、0なら左。2倍して0.5倍なので、そのまま。
half index = (frac(texcoordX) < 0.49) ? indexLeft : indexRight; // 非2羃テクスチャで誤差が出た時のために少し甘めに見ておく

uv.xにUnityが用意してくれている_MainTex_TexelSize.z、すなわちテクスチャの幅を乗じます。 これでテクスチャ座標がピクセル単位になります。 16色テクスチャは幅が半分なので、元が239であれば解像度は120です。つまり0から120の値が得られ、 元が左画素であれば小数部は0、右画素であれば小数部は0.5になります。 しかし誤差の問題があるため、0.49以下かどうかで判定しているわけです。

16色のバイリニアはこれに輪をかけて面倒くさいので、おヒマな方は解読してみてください。 バグを見つけてくださったならば、連絡を頂けると大変喜びます。

速度

さて、せっかく作ってしまったことですし、いかにも遅そうなこいつの速度を測ってみることにいたしましょう。 測定はMacBook Pro Mid 2014(i7 3GHz)にて、Chrome上のWebGLと、Standaloneビルドで行いました。

GPU側の速度測定は結構厄介でして、垂直同期(VSync)を切れない機械だと 60fpsをわずかに割った瞬間に30fpsに落ちてしまい、fpsで比較できません。 そこで、「25fps付近になるまで枚数を増やしたり減らしたりして調整し、その枚数をスコアとする」 という手法を取りました。30fpsと20fpsを往復するあたり、ということで、 ギリギリ30fpsから落ちる枚数を調べています。

枚数に比例してDrawCallが増えるので、あまりにCPUが遅い機械ではCPU負荷がネックになって シェーダ側の速度がわからなくなってしまう懸念がありますが、 全画面で描画すればさすがにGPUの方が遅いでしょう。

普通のImage Dummy 256 256Bilinear 16 16Bilinear
WebGL 190 117 120 103 104 73
Standalone 215 157 146 117 156 118

普通のImageが一番速いですね。Dummyというのは単に番号テクスチャをtex2Dするだけのシェーダです。 1行しかないので一番速くても良さそうなものですが、 マテリアルが別個でDrawCallが多い、ということが影響しているのかImageよりだいぶ遅くなりました。 Meshに頂点を自力で詰めてMeshRendererで描画すればDrawCallを増やさないので純粋なGPU負荷が見られると思いますが、 大抵の場合UnityEngine.UIを使うでしょうから、これで良しとします(誰かおねがいします)。

あとわかることは、バイリニアを有効にすると遅いということです。 とはいえ、1.5倍にもならない程度で、思ったほどではありませんでした。

WebGLとStandaloneで256色と16色の速度が逆転しているのは謎です。 テクスチャの幅が半分になる分だけメモリに負荷がかからないので16色の方が速いことはありえますが、 16色の方が余計な計算がある分だけシェーダ側は重くなるはずです。 そのあたりのバランスがWebGLとStandaloneで違うのでしょうか。よくわかりません。 スマホ実機でも見てみたいところですが、ビルドが面倒なので今回は割愛しました。 どなたか測ってくださったら大変喜びます。

古代の技術:パレットアニメーション

昔パレットが普通に使われていた頃には、「パレットだけを書き変えて番号テクスチャは共有し、 少ない容量で色違いを作る」という涙ぐましい手法が使われていました。

画像自体が1024x1024あっても、2048x2048あっても、 パレットだけを複数用意すれば、画像の色を丸ごと変えることができたのです。 PlayStation2以前の古代のゲームで色違いの絵があれば、これによって実現されている可能性が高いと言えます。 1Pカラーと2Pカラー、とか、ロープレの敵の色違い、とかですね。

また、定期的に明滅する部分は、パレットの明度を変化させて表現したりもしていました。 16色、あるいは256色だけを書き替えるなら高速ですから、CPUで毎フレーム変更することも可能です。 これは現代でもやろうと思えば可能です。せっかくなのでサンプルにそのような機能を入れておきました。

f:id:hirasho0:20190121065708g:plain

スライダーをいじると色が変わります。パレット内の色をHSVに変換して、 スライダーの値(0から360)をH(Hue:色相)に加え、 RGBに戻して書きこんでいます。 今ならCPUでやらずに、GPUでやる方がCPU⇔GPUの無駄な転送が減って望ましいでしょう。

終わりに

「思ったよりは遅くなかったけど、普通に考えてETC2だよね」 ということで、結論は変わりません。

GLES3以上の機械だけを相手にするなら、ETC2をまず試すべきかと思います。 不透明なのであればGLES2でもETC1が使えます(古いiPhoneを切らない場合は難しいですが)。 16色にしてもっと容量を削りたい、という場合も、アルファなしのETC1/ETC2なら同じ容量です。

パレットの出番が来るとすれば、画質に耐えられない時や、 古いiPhoneまで含めたGLES2の機械でも動かないといけない場合くらいでしょうか。 あとはパレットアニメーション。

とはいえ私としては頭の片隅には置いておこうかなと思っています。 圧縮に時間がかからず、アーティストの所で圧縮後の状態がわかる、 というのは地味にうれしい特徴なので。