Unityでパレット+インデックスカラーテクスチャを描画してみる

はじめまして、セガネットワークスカンパニーの関と申します。
iOS/Androidでサービス中のタイトル「戦の海賊」のクライアントサイドのプログラマです。
普段は育成パート(アジトやマップなどバトル以外全部)のUIやシステム関連を主に担当しています。レンダリングの専門家ではないので間違いがございましたらご容赦ください。


VRAMはいつの時代も足りないものでテクスチャサイズを削減する為に圧縮テクスチャを試したものの画質に我慢出来ずに結局フルカラーに戻した経験はありませんか?
私はいっぱいあります。

ここで「戦の海賊」の幼なじみキャラ、カーシャさんに登場していただきましょう。
f:id:sgtech:20170223101232p:plain
左がフルカラーのカーシャさんで右が8bitカラー(256色)のカーシャさんです。どうですか?違いがわかるでしょうか?*1

私はこういったアニメ調で輪郭のはっきりした絵は8bitカラーが最適だと思います。*2

ならばどんどん使えば良いと思うのですが、大変残念なことに今時のGPUはパレット+インデックスカラーのハードウェアデコードを捨ててしまいました。

最近はChromaPack*3が提唱されてプログラマブルシェーダが頑張れば色のリアルタイムデコードもなんとかなることがわかりました。
ChromaPackは素晴らしいアイディアですが、標準で1ピクセル12bitで複数ビットのアルファチャネルを考慮するともう一声削減したいところです。

ということでプログラマブルシェーダでパレット+インデックスカラーのデコードにチャレンジしてみましょう。

実装

以下のサンプルはUnity5.3.xで実装しました。

まずは256色に減色します。
減色にはOPTPiX imesta*4を使用しました。アルファ成分も考慮したパレットを生成出来る優れ物です。

f:id:sgtech:20170303190039p:plain

さて減色した画像をUnityにインポートするとパレット情報は何処かに飛んで行ってしまいますが心配はいりません。256色しか使われていないTrueColor(RGBA32bit)のテクスチャとしてインポートされますますので、インポートスクリプトでパレットを再構成し、パレット成分とインデックス成分を分離して二つのテクスチャを生成します。

// ImporterTextureCLUT.cs
using System.IO;
using UnityEngine;
using UnityEditor;
using System.Collections;
using System.Collections.Generic;

class ImporterTextureCLUT : AssetPostprocessor
{
	// src_pathに入っている画像から_clut、_indexのテクスチャを生成しdst_pathへ格納する
	string src_path = "Assets/src/";
	string dst_path = "Assets/dst/";

	void OnPreprocessTexture()
	{
		var importer = (assetImporter as TextureImporter);
		if (importer.assetPath.Contains( src_path )) 
		{
			// src_pathのテクスチャから正常にピクセルを取り出せるようにフォーマットを変更
			TextureImporter textureImporter = (TextureImporter)assetImporter;
			textureImporter.filterMode = FilterMode.Point;
			textureImporter.isReadable = true;
			textureImporter.textureFormat = TextureImporterFormat.RGBA32;
			textureImporter.mipmapEnabled = false;
		}
	}

	void OnPostprocessTexture( Texture2D texture )
	{
	    var importer = (assetImporter as TextureImporter);

		if(!importer.assetPath.Contains(src_path))
			return;
		// 全てのピクセルをなめてカラーの辞書(パレット)を生成する
		Dictionary<Color32,int> clut_dic = new Dictionary<Color32,int>();
		Color32[] tex32bit = texture.GetPixels32();
		int index = 0;
		foreach( Color32 color in tex32bit )
		{
			// 初めて出現した色に新しいインデックスを割り当てる
			if( !clut_dic.ContainsKey( color ) )
			{
				clut_dic[color] = index;
				index ++ ;
				if( index>256 )
				{
					// 256色以上あったら中断
					Debug.LogError( "over 256!!!!" );
					return;
				}
			}
		}
		// パレットの辞書が出来たのでテクスチャとして出力しPNGにエンコードして保存
		Texture2D tex_clut = new Texture2D( 256, 1, TextureFormat.RGBA32, false );
		tex_clut.filterMode = FilterMode.Point;
		Color32[] clut_array = new Color32[256];
		foreach( Color32 val in clut_dic.Keys )
		{
			clut_array[clut_dic[val]] = val;
		}
		tex_clut.SetPixels32( clut_array );
		byte[] clut_png_binary = tex_clut.EncodeToPNG();

		// パレットの辞書からインデックスを取得しアルファチャネルに設定し、PNGにエンコードして保存
		Texture2D tex_index = new Texture2D( texture.width, texture.height, TextureFormat.RGBA32, false );
		Color32[] index_array = new Color32[tex32bit.Length];
		int i=0;
		foreach( Color32 color in tex32bit )
		{
			index_array[i] = new Color32( 255, 255, 255, (byte)clut_dic[color] );
			i++;
		}
		tex_index.SetPixels32( index_array );
		byte[] index_png_binary = tex_index.EncodeToPNG();


		// 出力用の名前を作成
		string name = importer.assetPath.Substring( importer.assetPath.IndexOf( src_path ) + src_path.Length );
		name = name.Substring( 0, name.LastIndexOf( '.' ) );

		// パレットテクスチャを出力
		FileStream fs = new FileStream( dst_path + name + "_clut.png", FileMode.Create, FileAccess.Write );
		fs.Write( clut_png_binary, 0, clut_png_binary.Length );
		fs.Close();
		// インデックステクスチャを出力
		fs = new FileStream( dst_path + name + "_index.png", FileMode.Create, FileAccess.Write );
		fs.Write( index_png_binary, 0, index_png_binary.Length );
		fs.Close();
	}
}


今回は手を抜いて出力されたテクスチャのインポート設定は手動で変更します。実際に使用することになったらインポートスクリプトでマテリアルまで一気に作れる様にすると便利でしょう。

横に細長いテクスチャはパレットを画像化したものです。
f:id:sgtech:20170223101110p:plain
FormatはAutomatic TrueColor、MipMapはoff、WrapModeはClamp、FillterModeはPointにします。
いきなりTrueColorにしてしまいましたが256ドット*4バイトでたったの1Kbyteです。必要経費だと思いましょう。
f:id:sgtech:20170223101121p:plain

灰色で良く分からない感じになったカーシャさんはインデックスをグレースケールで表現したものです。
f:id:sgtech:20170223102357p:plain
FormatはAlpha8bit、Alpha from grayscaleをon、MipMapはoff、WrapModeはClamp、FillterModeはPointにします。
f:id:sgtech:20170223101132p:plain

あとはこれらを組み合わせてプログラマブルシェーダでデコードするだけです。コードは極めてシンプルです。

// CLUT+Index.shader
Shader "Unlit/CLUT+Index" 
{
	Properties 
	{
		_Clut ("Clut", 2D) = "white" {}
		_Index ("Index", 2D) = "white" {}
	}
	SubShader
	{

		Tags {"Queue"="Transparent" "IgnoreProjector"="True" "RenderType"="Transparent"}
		ZWrite Off
		Blend SrcAlpha OneMinusSrcAlpha 

		Pass
		{
			CGPROGRAM
			#include "UnityCG.cginc"
			#pragma vertex vert_img
			#pragma fragment frag

			sampler2D _Clut;
			sampler2D _Index;

			half4 frag(v2f_img i) : SV_Target 
			{
				// インデックスをカラーとして取り出す
				// (※必ずポイントサンプリングにすること。フィルタをかけるとインデックスが壊れます)
				half4 index_tex = tex2D(_Index,i.uv);
				// インデックスのアルファ成分をパレットテクスチャのu値としてカラーを取り出す
				half4 c = tex2D(_Clut, index_tex.a );
				return c;
			}

			ENDCG
		}
	}
}

マテリアルを新規作成し、シェーダを上のものに設定し、CLUTに細長いパレットテクスチャを設定、Indexにグレーのカーシャさんを設定します。

f:id:sgtech:20170223101143p:plain

適当に平面ポリゴンに貼り付けて表示してみましょう。

f:id:sgtech:20170223102418p:plain

出来た!

と思いきや、スクリーンサイズを変えると…

f:id:sgtech:20170223102429p:plain

なんとも無残にガビガビになってしまいました。

インデックステクスチャをポイントサンプリングしているので当然といえば当然です。バイリニアフィルタをかけるとインデックスに補完がかかり正確な位置のカラーが取り出せず滅茶苦茶な絵になってしまいます。

さてどうしたものでしょう。

画質向上

対処法は色々あると思いますが、万能な方法はなく状況に合わせた対処法になるでしょう。ここでは考え方の提示に留めます。
ご興味ある方は是非セガグループの仲間になって一緒に考えましょう!*5

1.諦めてそのまま使う。

いい加減で恐縮ですがスクリーンの1ピクセルとテクスチャの1ピクセルが正確に対応する様に注意して描画すれば問題ありません。*6
スクリーンサイズが変わる状況では画像の占めるサイズが変わってしまうのでレイアウトに工夫が必要です。
ターゲットのスクリーンサイズに合わせて複数サイズのテクスチャを用意するという戦略もありでしょう。

2.レンダーテクスチャ使う

フィルタのかかるレンダーテクスチャを仮想スクリーンとしてピクセルが拡大縮小されないように描画し、実スクリーンにぴったり収まる様にリサイズして描画します。
巨大な32bitカラーのレンダーテクスチャが必要なので本末転倒ですが、オーバヘッドを回収出来るくらい大量に描けばメリットはあるかもしれません。

3.シェーダでバイリニアフィルタをかける

ポイントサンプリングではテクスチャのUV値は線形に増加しますがピクセル単位に丸めた一点だけがサンプリングされます。
UV値をずらしながら4点サンプリングしパレットからカラーを取得、UV値の端数を使用して線形補間をかけたカラーがバイリニアフィルタをかけた状態になります。
f:id:sgtech:20170223102437p:plain
多少の変形では画質が破綻しないので、扱いやすさは抜群です。
問題は処理負荷です。

処理負荷

ここまで処理負荷について目をつぶって来ましたがリアルタイムに動作するゲームにおいて無視できない要素です。
フレームレートも心配ですが、バッテリ消費量、発熱の問題も現在では無視できません。
今回のプログラムは一見極めてシンプルですが、フラグメントシェーダを使用しているためピクセル数分、関数が呼び出しが行われます。描画先が1024*1024ピクセルであれば、100万回もの呼び出しが行われます。それも毎フレーム!
処理負荷はざっくり言うと命令数と外部メモリへのアクセス回数に依存します。
さらに現在ではどれだけ並列に同時実行できるかが重要な鍵になります。

フォーマット 命令数 メモリへのアクセス回数
TrueColor 2 1
パレット+8bitインデックス 5 2

命令数は多少増えますが並列実行の恩恵を受けられればさほどインパクトはないでしょう。
しかしながらメモリアクセスが2回行われ、1回目の結果(インデックス)が取得完了しないと2回目のメモリアクセス(パレット)が取得できないため読み込み待ちが発生してしまいます。
さらに言うとパレットへのアクセスはランダムな座標を取得しないといけないので、順次テクスチャ座標が増加する想定のGPUではロスが発生するでしょう。
マルチコアで並列に実行するといっても局所的なメモリアクセス(同じ座標のテクスチャ読み込み)では読み込み待ちが頻発してしまい並列実行はできなくなってしまいます。*7

色々考察してみましたが、結局の所リアルタイムで動くかどうかなので何はともあれベンチマークを取ってみましょう。

f:id:sgtech:20170223102454p:plain

フォーマット スコア
TrueColor 58Kasha
パレット+8bitインデックス 39Kasha
パレット+8bitインデックス+バイリニアフィルタ 10Kasha
ChromaPack 15Kasha

謎の単位Kashaの詳細は諸事情により伏せますが、数字が大きい方がパフォーマンスが良いと思ってください*8

単純にテクスチャを読み込む回数に大体比例する結果となりました。この位ならギリギリ実用に耐えるのではないでしょうか。
もちろんGPUのアーキテクチャによって結果は大きく変わりますので慎重な検証が必要です。

応用

パレットはメモリ使用量の削減に効果的ですが、醍醐味はパレットチェンジによる色変えです。インデックスはそのままにカラーバリエーションを持つような事が簡単に実装出来ます。
f:id:sgtech:20170223102503g:plain

パレットテクスチャ自体をプログラマブルシェーダで動的に生成すればパレットアニメーションのような事も出来るでしょう。

また、多少ロジックは複雑になりますが4ビットカラー(16色)の実現も可能です。UIでは16色で十分なケースも多いでしょう。

まとめ

  • ハードウェアが対応していなくてもプログラマブルシェーダを使用すればパレット+インデックスカラーの描画は可能。
  • 画素が変形(拡大縮小など)するケースでは汚くなるので何らかの対策が必要。
  • フラグメントシェーダでのバイリニアフィルタは高コスト(遅い)なので、使い所に注意する。
  • 工夫次第でレガシーなパレット技がいろいろ使えるので可能性は無限大
  • サンプルでさんざんこき使われたカーシャさんの出てくる「戦の海賊」は大好評サービス中なので是非プレイしよう!

www.sen-no-kaizoku.jp

それでは次回の投稿をお楽しみに。


戦の海賊 © SEGA

*1:ちなみにフルカラーのカーシャさんは26727色使用されていました。

*2:逆に輪郭のはっきりしない背景のような絵は圧縮テクスチャでも十分だと思います。

*3:https://github.com/keijiro/ChromaPack/blob/master/README_ja.md

*4:OPTPiX imésta 7 シリーズ | ウェブテクノロジ

*5:私はセガネットワークスカンパニーの所属なのでこちらをおすすめしておきます。
採用情報 |株式会社セガゲームス セガネットワークス カンパニー -【SEGA Games Co., Ltd. SEGA Networks Company】

*6:いわゆるPixelPerfectと呼ばれる描画方法。例えばスクリーンが1080pであればカメラをOrthographicにしてカメラサイズを540とした時ワールド座標の1*1の矩形は1ピクセルで描画されます。

*7:この辺りがパレットが廃止された大きな要因でしょう

*8:読み方はキロアシャじゃなくてローマ字読みして間に「ー」を入れます

Powered by はてなブログ