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:読み方はキロアシャじゃなくてローマ字読みして間に「ー」を入れます

セガグループ・インターンシップで学生とゲームを作った話

みなさま、はじめまして。
セガ・インタラクティブ 第二研究開発本部 プログラマーの日髙(ひだか)と申します。

普段はアーケードゲームのクライアントサイドの実装やツール作成、
アセットパイプライン構築などを行っています。
今回のブログ執筆を担当することになりました。
どうぞよろしくお願いします!

年明け一発目ということで小難しい話は置いておき、
昨年秋に開催された「セガグループ・インターンシップ」の取り組みのうち、
ゲームジャム形式で行われた「ゲーム制作体験」についてご紹介します。
また、実際に私もサポートスタッフとして学生とゲーム制作を行ったので、
当日の役割とそこから得られたものについて共有したいと思います。

セガグループ・インターンシップとは

ゲーム業界を志望している、もしくは興味のある学生を対象に、
セガグループ各社が合同で開催するインターンシップになります。
今回は「ゲーム制作体験」と「職種体験」の2種類の取り組みが行われました。

「ゲーム制作体験」では学生とサポートスタッフが一緒のチームになり、
2日間でゲームを1本作るといった内容、
「職種体験」では現場で使用されているツールの説明と、
ツールを使用した作業工程を学生に体験してもらうといった内容になっています。

これらの取り組みを通じてゲーム業界での仕事や制作の工程を知ってもらうだけでなく、
実際に現場のクリエイターと一緒にセガのものづくりを体験することで、
ゲーム業界により一層興味を持ってもらうことを目的としています。

続いて「ゲーム制作体験」の内容についてもう少し掘り下げたいと思います。

ゲーム制作体験について

今回のゲーム制作体験では「ゲームジャム形式」を採用しており、
2日間で合わせて18時間という短い時間でゲーム制作を行いました。

各チームの構成メンバーは以下の通り。

職種 学生 サポートスタッフ
プランナー 2名 1名
デザイナー 2名 1名
プログラマー 2名 1名

1チームあたり9名ずつ、合計5チームが形成されました。
当日はこの他に運営スタッフとチーム外のサポートスタッフが数名参加しました。

今回はサポートスタッフのスキルや開発の手軽さなどを考慮して、
ゲームエンジンにUnityを採用しました。

開発環境については会社側で全て手配しており、
開発用WindowsマシンにUnityを含む各種ソフトがインストールされた状態で各自1台ずつ提供されました。
また、開発ターゲットとしてAndroid端末も各チームに2台ずつ配られました。

そして、「セガっぽい」をテーマに各チームがゲーム制作に取り組みました。

ゲーム制作体験の紹介は以上になります。

開催中の雰囲気などは先日公開された動画をご覧ください。

セガのモノ創りに触れてみよう!『ゲーム制作体験』紹介

サポートスタッフの役割

サポートスタッフはチーム内での実装作業はもちろんのこと、他にも様々な役割があります。
私が担当したチームを例にプログラマー視点での役割を具体的に紹介していきます。

チーム制作におけるUnity活用方法のレクチャー

Unityのチュートリアルをやったことがある、本を読みながら少し触った事がある、
といった初歩を脱して、実際にチーム制作でUnityを活用するための方法を説明しました。
以下にいくつか例を挙げます。

Unityの設定で変更した項目とその理由
Unityを用いて複数人で制作を行う場合、後述のバージョン管理ツールの使用に伴い、
metaファイルを表示したり、アセットをテキスト形式で保持したりする必要があります。
また、Android端末への出力のために、プラットフォームの切り替え、
ゲーム画面解像度の設定などを適切に行う必要があります。

制御のしやすさを意識したGameObjectの組み方
複数のGameObjectを一度に制御する場合に、それらをまとめる親GameObjectを作成して、
親GameObjectを操作することで各GameObjectの制御を簡単に行えるようになります。

もちろんスクリプトからすべてのGameObjectを個別に制御しても良いですが、
せっかくUnityを使用するのであれば、親子構造の特性を活用することで、
短い時間の中でも複雑な制御を手軽に行えるようになります。

アプリをビルドしてAndroid端末で実行する手順
Unity上であればアプリのビルドから実行までを1ボタンで行えますが、
ビルド済みのアプリを端末にインストールして実行する場合は別途手順が必要になります。

コンソールからadbコマンドでアプリをインストールするのですが、
毎回コンソールを開いてコマンドと引数を入力するのは手間なので、
バッチファイルを活用して単純作業を簡略化する方法を説明しました。

また、apkファイルをそのバッチファイルに関連付けすることで、
ビルド後のapkをダブルクリックするだけでインストールできるようになります。

バージョン管理ツールの説明と使い方のレクチャー

開発現場では当たり前のように使用されているバージョン管理ツールですが、
ゲーム制作自体やチーム制作の経験が無い学生にとっては馴染みがありません。

バージョン管理の仕組みやなぜそれらが必要になるのか、などを簡潔に説明したあとで、
実際に使うところを見せて、試してもらうといった対応を行いました。
2日目にもなると、競合した状態を学生たちだけでも解決できる程度に使いこなしていました。

作業分担するためのコーディング方法のレクチャー

個人制作とは異なり、チーム制作では複数人のプログラマーが作業分担してゲームを作り上げます。
その際にそれぞれが好き勝手に実装すると結合作業に時間がかかったり、
必要な機能が抜け落ちたりといった問題が起こりがちです。
そこでスムーズに結合するためにはどういう風に実装すれば良いか、
具体的な方法を提示して取り組んでもらいました。

今回制作したゲームではAndroid端末のセンサー情報を利用してキャラクターを操作する、
といった実装が必要だったため、学生2人と相談して、
「キャラクター制御」と「Android端末の入力取得」で作業分担を行いました。
その際にアドバイスした作業手順は以下の通りです。

キャラクター制御担当の作業

  1. キャラクター制御に必要な入力を管理するControllerクラスを作成する。
  2. Controllerクラスにキーボードなどのデバッグ入力を使用した仮実装を行う。
  3. Controllerクラスをコミットする。
  4. デバッグ入力を使用してキャラクター制御の実装を進める。

Android端末の入力取得担当の作業

  1. 前述のControllerクラスがコミットされるまでに、Android端末の入力取得を検証する。
  2. 仮実装でコミットされたControllerクラスを取得する。
  3. Android端末の入力取得処理をControllerクラスに反映する。
  4. Controllerクラスをコミットする。

事前にどのように作業分担するかを決めてから、
実装に取り掛かることでスムーズな結合を行うことができます。

チーム制作におけるプログラマーの立ち振舞いについてのアドバイス

チーム制作においてデザイナーやプランナーとのコミュニケーションは必要不可欠となります。
制作を円滑に進めるためにプログラマーとしてどういった立ち振舞いをすれば良いかをアドバイスしました。
アドバイスの内容を2つほど紹介します。

【1】仕様待ちをしない

自分の担当箇所について「仕様が決まらないから」という理由で待っていると、
いつまで経っても実装は進まず、本当にその実装で良いのかを判断する時間が減ってしまいます。
プランナーのやりたい事を汲み取り、こちらから案を提示したり、
実装したものを見せて判断してもらったり、といった動きができると良いです。

【2】作成するデータの形式を決める

ゲームに必要なデータを組み込んだり、読み込んで使用したりと、
最終的にデータを扱うのはプログラマーになることが多いです。
そのため扱い易さや特徴を考慮してプログラマーからデータの形式を指定する必要があります。
また、早めに形式が決まっていると作業の手戻りを防ぐことにも繋がります。

参加して得られたもの

サポートスタッフとして学生と一緒にゲーム制作を行うことで私自身多くのことを学びました。
経験に差がある相手に教える難しさや、学生本来の能力を発揮してもらうために導く難しさを痛感しました。
また、学生にアドバイスした内容について「自分はその通りにできているか?」と振り返る良い機会にもなりました。

これらは学生だけで取り組むグループワークを見ているだけでは得がたい、貴重な経験となりました。
今後の業務や新人・後輩の育成にも活かせるのではないかと思います。

まとめ

学生と共にゲーム制作を行うインターンシップは他の事例をあまり聞いたことが無く、
セガでも初の試みでしたが、参加した学生の満足度も高く、
スタッフ一同得るものがあり大変有意義でした。

このエントリーが学生とものづくりをされる方や、ものづくりについて教える方の参考になれば幸いです。

次回のセガグループ・インターンシップの開催については未定ですが、
私の所属するセガ・インタラクティブ 第二研究開発本部では、
このような取り組みにも積極的な方と一緒に働きたいと考えています。
もしご興味を持たれましたら、弊社グループ採用サイトをご確認ください。

採用情報 | セガグループ



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

DDSファイルを自力で読んでみよう

  • みなさん初めまして。セガゲームス開発技術部の内田です。

普段は、ゲーム開発に利用するツールやライブラリなどを作成する仕事をしています。
今回は、そのツール開発で必要になった機能を1つ取り上げ、その実装についてお話してみたいと思います。

今回のお題:DDSファイルを自力で読んでみよう

DDSファイルと言えば、ゲームなどのグラフィックスプログラムを作成するときによく利用される画像ファイルフォーマットです。
これは、JPEGやPNGといった画像フォーマットの1つなのですが、DirectXなどで簡単かつ高速に扱えるという利点があります。
DirectXに用意されている機能を利用すれば、簡単にテクスチャとして読み込み、ポリゴンに貼り付けたりすることができます。

そのため、読み込みよりも、どのように高品質のDDSファイルを作成するかが重要な点であり、そのために多くの優秀なソフトウェアや圧縮のノウハウが開発、考案されてきたと言えます。

ですが、今回はDDSへの変換ではなく、読み込みを、ゼロから書いたコードで、自力でやってみましょう。
これができるようになると、

  • ピクセルデータを展開してRGBAの情報を直接利用したい。
  • DirectXやOpenGL、その他ライブラリなどを利用したくない、できない。
  • マネージコードだけで読み込みたい。
  • 何らかのツールのインポートプラグインを書きたい。
  • 他の画像形式に変換したい。
  • 画像の情報(例えば、横幅や高さ、圧縮形式)を取得したい。

などどいった場合に役立つかもしれません。


今回は以下の条件のもと、進めていきたいと思います。

  • 使用言語はC#(なるべく新しい Visual Studio)
  • DDSファイルを読み込み、System.Drawing.Bitmap クラスに変換(WinFormsアプリケーションを想定)
  • DXT1形式のみ対応(最後にBC1にも対応)
  • ミップマップには対応しない

また、処理を簡潔にわかりやすくするため、エラー処理や例外処理は最低限にしたいと思います。

今回はDDSの中でもDXT1形式というレガシーな形式のみを取り上げますが、自分の書いたコードだけで読めるようになるというのは面白いのではないでしょうか。

それでは、さっそく始めましょう。

目次

テストプログラムの作成

いきなりDDSファイルの読み込み部分を作成、、、といきたいところですが、実際には、ファイルを読み込ませながら、少しずつデバッグし、完成させていくことなります。
そこで、まずはドラッグ&ドロップで簡単にDDSファイルを読み込み表示できるようなテストプログラム(System.Windows.Forms 利用)を作成してしまいます。

f:id:sgtech:20161222105257p:plain

フォームデザイナでPictureBoxを追加した後、以下のようにドラッグ&ドロップのコードを書きました。

public Form1()
{
    InitializeComponent();

    AllowDrop = true;
    DragEnter += (sdr, ea) => ea.Effect = DragDropEffects.All;
    DragDrop += (sdr, ea) => {
        var fname = ((string[])(ea.Data.GetData(DataFormats.FileDrop))).FirstOrDefault();
        pictureBox1.Image = DdsLoader.LoadFromFile(fname);
    };
}


ですが実際のところ、開発中は何度も何度も同じ画像を読み込むことになるので、次のようにしてしまった方が楽かもしれません。

protected override void OnShown(EventArgs e)
{
    base.OnShown(e);
    pictureBox1.Image = DdsLoader.LoadFromFile(デバッグに使用するDDSファイルのフルパス);
}

そして、ここから

public static class DdsLoader
{
    public static System.Drawing.Bitmap LoadFromFile(string filename)
    {
    }
}

の中身をを少しづつ実装していきます。

DDSファイルヘッダのすごく簡単な構造

DDSファイルのヘッダは128バイトもあり、複雑な構造となっているのですが、今回は、ごく簡単なパラメータのみの取得で十分です。
必要となるのは、赤枠で示された4ヶ所だけです。

f:id:sgtech:20161222105213p:plain

ヘッダ情報を格納するクラスを作る

まずはDDSファイルのヘッダから必要な情報を抜き出してまとめるためのクラスを作りましょう。例えばこのようになります。

public class DdsSimpleFileInfo
{
    /// <summary>
    /// この情報が有効であることを示す値を取得または設定します。
    /// </summary>
    public bool IsValid { get; set; }

    /// <summary>
    /// 画像の横幅を取得または設定します。
    /// </summary>
    public int Width { get; set; }

    /// <summary>
    /// 画像の高さを取得または設定します。
    /// </summary>
    public int Height { get; set; }

    /// <summary>
    /// ピクセルデータの開始位置を取得または設定します。
    /// </summary>
    public int PixelOffset { get; set; }

    /// <summary>
    /// ファイル形式を示す文字列を取得または設定します。
    /// </summary>
    public string Format { get; set; };
}

ヘッダを解析する

解析を始める前に、まずは必要となるメソッドをいくつか実装していきましょう。ここでは、BinaryUtility というクラスにまとめました。
これは、標準で用意されている BitConverter.ToInt32() や BitConverter.ToInt16()で、リトルエンディアン環境では十分なのですが、自作してしまいましょう。

public static class BinaryUtility
{
    /// <summary>
    /// 指定したバイト列のオフセット位置に格納されている、32ビット無符号整数を取得します。
    /// </summary>
    /// <param name="bytes"></param>
    /// <param name="offset"></param>
    /// <returns></returns>
    public static uint MakeUInt32(byte[] bytes, int offset)
    {
        return MakeUInt32(bytes[offset], bytes[offset + 1], bytes[offset + 2], bytes[offset + 3]);
    }

    public static uint MakeUInt32(byte a, byte b, byte c, byte d)
    {
        return (uint)((a) | ((b) << 8) | ((c) << 16) | ((d) << 24));
    }

    /// <summary>
    /// 指定したバイト列のオフセット位置に格納されている、16ビット無符号整数を取得します。
    /// </summary>
    /// <param name="bytes"></param>
    /// <param name="offset"></param>
    /// <returns></returns>
    public static ushort MakeUInt16(byte[] bytes, int offset)
    {
        return (ushort)((bytes[offset]) | ((bytes[offset + 1]) << 8));
    }
}

次のようなメソッドを作成し、読み込んだバイト列から 先ほど定義した DdsSimpleFileInfo クラスに内容を取得します。
先にも書いたように、本来であれば複雑なフラグ類をたくさん解析しないといけないのですが、今回はすごく簡略化していますので、やることは次の4つだけとなります。

  • ヘッダの先頭に "DDS{スペース}" という文字列があるかどうかを調べる
  • 横幅と高さを取得する
  • 圧縮形式が "DXT1" かどうかを調べる
  • ピクセルデータ開始位置を取得する


失敗しても null を返さずに、IsValidがfalseとなっている DdsSimpleFileInfo インスタンスを返すようにしておいたほうが使い勝手が良いでしょう。

以上をまとめてメソッドを作成すると、次のようになるでしょうか。

public static class DdsLoader
{
    public static DdsSimpleFileInfo GetDdsInfo(byte[] data)
    {
        const int HeaderSize = 0x80;

        var info = new DdsSimpleFileInfo();    // 情報クラスを作成

        if (data.Length < HeaderSize) {
            return info;        // DDSとしてのヘッダサイズが足りない
        }

        var magic = Encoding.UTF8.GetString(data, 0, 4);        // data 配列の先頭から連続する4バイトを文字列に変換
        if (magic != "DDS ") {
            return info;        // DDSファイルではない
        }

        // 画像の横幅と高さ
        info.Height = (int)BinaryUtility.MakeUInt32(data, 0x0c);        // data 配列の 12バイト目から連続する4バイトをInt32に変換
        info.Width = (int)BinaryUtility.MakeUInt32(data, 0x10);         // data 配列の 16バイト目から連続する4バイトをInt32に変換

        info.PixelOffset = HeaderSize;        // ピクセルデータの開始位置

        // DXT1形式かどうかをチェック
        info.Format = Encoding.UTF8.GetString(data, 0x54, 4);
        switch (info.Format) {
            case "DXT1":
                break;
            default:
                return info;
        }

        info.IsValid = true;            // ヘッダ取得成功を示すフラグを設定

        return info;
    }
}

さて、ヘッダ情報が解析できるようになったので、それを使ったファイル読み込みのメソッドは次のようになります。

public static class DdsLoader
{
    public static System.Drawing.Bitmap LoadFromFile(string filename)
    {
        // ファイルをバイト列に読み込む
        byte[] data = System.IO.File.ReadAllBytes(filename);

        // ヘッダを解析
        var info = GetDdsInfo(data);
        if (info.IsValid) {
            switch (info.Format) {
                case "DXT1":
                    // DXT1形式なら解凍してBitmapに変換
                    return DXT1Decoder.CreateBitmap(data, info.Width, info.Height, info.PixelOffset);
            }
        }

        return null;
    }
}

ここからはDXT1形式で格納されているピクセルデータを展開していきましょう。

DXT1形式をデコードする

ブロックの構成

DXT1形式では横4ピクセルx縦4ピクセルの情報を圧縮、格納した「ブロック」から構成されています。
1ブロックは必ず8バイトで、次のようになっています。

f:id:sgtech:20161222105215p:plain

いくつのブロックが入っているの?

1ブロックで4x4ピクセルが構成されますが、たとえば1x3や2x2の画像の場合においても、1ブロックが使われることになっています。
つまり、縦、横それぞれのピクセル数を4で割って、余りがあればさらに1ブロック余計に必要になるということになります。

通常、テクスチャとして使用される画像は、512x512 や 2048x1024 などどいった、2のn乗サイズが基本ですので、4の倍数でないということはほとんどないのですが、将来的にミップマップに対応することを考えると、2x2 や 2x1といったサイズも最初から考慮してあげた方が良いでしょう。


まずは、ピクセル数から必要なブロック数を計算する処理をメソッドにしておきましょう。

public static class DXTUtility
{
    /// <summary>
    /// 指定されたピクセルサイズを格納するために必要なブロックの個数を取得します。
    /// </summary>
    /// <param name="pixelSize">ピクセル数</param>
    /// <returns></returns>
    public static int GetBlockCount(int pixelSize)
    {
        return (pixelSize >> 2) + (((pixelSize & 3) != 0) ? 1 : 0);
    }
}

さらに、画像全体でいくつのブロックが格納されているかを調べて、すべて列挙するようなメソッドを作成します。

    public struct BlockInfo
    {
        public int X;        // ピクセル位置X
        public int Y;        // ピクセル位置Y
        public int Offset;    // ブロックデータオフセット

        public BlockInfo(int x, int y, int offset)
        {
            X = x;
            Y = y;
            Offset = offset;
        }
    }

public static class DXTUtility
{
    /// <summary>
    /// 指定された横幅、高さの画像のブロック情報をすべて列挙します。
    /// </summary>
    /// <param name="width">横幅</param>
    /// <param name="height">高さ</param>
    /// <param name="dataOffset">ファイル中のブロック先頭オフセット</param>
    /// <param name="blockSize">1ブロックのバイト数</param>
    /// <returns></returns>
    public static IEnumerable<BlockInfo> EnumerateBlocks(int width, int height, int dataOffset, int blockSize)
    {
        int horizontalBlockCount = GetBlockCount(width);
        int verticalBlockCount = GetBlockCount(height);

        int offset = dataOffset;
        for (int i = 0; i < verticalBlockCount; i++) {
            for (int j = 0; j < horizontalBlockCount; j++) {
                yield return new BlockInfo(j * 4, i * 4, offset);
                offset += blockSize;
            }
        }
    }
}
デコード開始

さてブロックが列挙できるようになったので、デコード機能を徐々に作成していきましょう。
このような仕様のメソッドにしておきましょうか。
DdsSimpleFileInfo クラスを引数に取らず、width, height, pixelOffset に分けて渡す仕様にしているのは、将来的にミップマップに対応する布石とでも考えておいてください。

public static class DXT1Decoder
{
    public static System.Drawing.Bitmap CreateBitmap(byte[] data, int width, int height, int pixelOffset)
    {
    }
}

まずは縦横それぞれのブロック数を取得し、それに4を掛けて、物理的なピクセル数を計算し、展開先のバッファを用意します。
展開先のバッファサイズは、
横物理ピクセル数 x 縦物理ピクセル数 x 4バイト (RGBA)
となります。

その後、すべてのブロックを処理するようループを記述します。

    int BlockSize = 8;

    int physicalWidth = DXTUtility.GetBlockCount(width) * 4;
    int physicalHeight = DXTUtility.GetBlockCount(height) * 4;

    var pixels = new byte[physicalWidth * physicalHeight * 4];

    var blocks = DXTUtility.EnumerateBlocks(width, height, pixelOffset, BlockSize);

    foreach (var block in blocks) {
        // ここで block をデコードし pixel 配列の該当位置にRGBAデータを展開する
    }
カラーの取り扱い


DXT1ブロックの色情報は、 RGB成分がそれぞれ5ビット、6ビット、5ビットとして16ビットにパックされています。
この状態から色の計算を簡潔に記述するため、次のような構造体を作成しておきましょう。

public struct ColorRgba
{
    public float R; // 赤成分(0.0~1.0)
    public float G; // 緑成分(0.0~1.0)
    public float B; // 青成分(0.0~1.0)
    public float A; // アルファ成分(0.0~1.0)

    /// <summary>
    /// ColorRgba構造体の新しいインスタンスを初期化します。
    /// </summary>
    public ColorRgba(float r, float g, float b, float a)
    {
        R = r;
        G = g;
        B = b;
        A = a;
    }

    /// <summary>
    /// RGB565形式の16ビットカラーデータから ColorRgbaインスタンスを生成します。
    /// </summary>
    public static ColorRgba FromRgb565(ushort rgb565)
    {
        int r = (rgb565 >> 11) & 0x1f;
        int g = (rgb565 >> 5) & 0x3f;
        int b = (rgb565 >> 0) & 0x1f;
        return new ColorRgba(r * 1.0f / 31.0f, g * 1.0f / 63.0f, b * 1.0f / 31.0f, 1.0f);
    }

    /// <summary>
    /// 指定された2つのカラーをリニア補間して中間色を求めます。
    /// </summary>
    public static ColorRgba Lerp(ColorRgba x, ColorRgba y, float s)
    {
        float r = x.R + s * (y.R - x.R);
        float g = x.G + s * (y.G - x.G);
        float b = x.B + s * (y.B - x.B);
        float a = x.A + s * (y.A - x.A);
        return new ColorRgba(r, g, b, a);
    }
}
1ブロックのデコード

まずはブロックの先頭2バイトからカラー0,次の2バイトからカラー1を読み込みます。

    ushort color0 = BinaryUtility.MakeUInt16(data, block.Offset);
    ushort color1 = BinaryUtility.MakeUInt16(data, block.Offset + 2);

この color0 と color1 から、4x4のブロックの各ピクセルの色を計算で求めていきます。

4x4の各ブロックの色は2ビットのインデックスとなっています。インデックスが2ビットということは0,1,2,3のいずれか、つまり、4色の中からどれか1色を選んで、そのピクセルの色を決めるということになります。
そこで、あとでインデックスで引きやすいよう、4色を格納するための配列を作成します。

    var colors = new ColorRgba[4];
アルファの判定と4色の求め方

DXT1のブロックには、いわゆる1ビットアルファ(パンチスルー)の情報を格納することもできます。
color0 と color1 を16ビット無符号整数として単純に大小を比較し、どちらが大きいかによって、

  • 4色すべてを色情報として扱う
  • 3色の色情報+透明

のどちらかを選んで格納することができます。
4色の場合は、カラー0, カラー1, そしてそれらの中間色を2色、
3色+透明の場合は、カラー0,カラー1, 中間色1色、透明、を求めます。

次のようなコードでそれを判定、計算し、カラーテーブルの内容を求めています。

if (color0 > color1) {
    // アルファなし。使える色は4色
    colors[0] = ColorRgba.FromRgb565(color0);                            // カラー0そのまま
    colors[1] = ColorRgba.FromRgb565(color1);                            // カラー1そのまま
    colors[2] = ColorRgba.Lerp(colors[0], colors[1], 1.0f / 3.0f);       // color_2 = 2/3*color_0 + 1/3*color_1
    colors[3] = ColorRgba.Lerp(colors[0], colors[1], 2.0f / 3.0f);       // color_3 = 1/3*color_0 + 2/3*color_1
} else {
    // アルファあり。使える色は3色
    colors[0] = ColorRgba.FromRgb565(color0);                            // カラー0そのまま
    colors[1] = ColorRgba.FromRgb565(color1);                            // カラー1そのまま
    colors[2] = ColorRgba.Lerp(colors[0], colors[1], 1.0f / 2.0f);       // color_2 = 1/2*color_0 + 1/2*color_1
    colors[3] = new ColorRgba(0, 0, 0, 0);                               // 透明
}
インデックスの展開

4色のテーブルの内容が決まりましたので、ここからは 4x4の各ピクセルにその内容を書き込んできます。
インデックスの情報は2ビットx16ピクセル分ということで、32ビットに収まりますので、uintの変数に読み込んでしまいましょう。
そして、そこから下位2ビットずつ取り出し、4色のテーブルの色を参照していきます。
ピクセルデータ書き込み先には、0~255の形でB,G,R,Aの順でカラーを格納していきます。

uint indexBits = BinaryUtility.MakeUInt32(data, block.Offset + 4);

for (int y = 0; y < 4; y++) {
    for (int x = 0; x < 4; x++) {
        var idx = indexBits & 0x03;                   // インデックスの取り出し
        var col = colors[idx];                        // カラーテーブルからインデックスでカラーを取り出す
        int xx = block.X + x;
        int yy = block.Y + y;
        int p = (xx + yy * physicalWidth) * 4;        // ピクセルの書き込み位置
        pixels[p + 0] = (byte)(col.B * 255.0f);       // 青(0~255)
        pixels[p + 1] = (byte)(col.G * 255.0f);       // 緑(0~255)
        pixels[p + 2] = (byte)(col.R * 255.0f);       // 赤(0~255)
        pixels[p + 3] = (byte)(col.A * 255.0f);       // アルファ(0~255)
        indexBits >>= 2;                              // インデックスを2ビット右シフトして次に備える
    }
}

ここまでの処理で、byte[] pixels という配列にピクセルデータが展開できました。
あとはこれをSystem.Drawing.Bitmapにしてあげれば、WinFormのアプリで取り扱いやすくなりますね。

System.Drawing.Bitmap のコンストラクタには、何と都合の良いことに「ストライド値」を指定できるバージョンがあります。
ストライド値とは一般的に、「次のデータは何バイト先から始まるか」ということを示すために使われる値のことを示します。
今回の場合、「次のピクセル行は何バイト先に格納されているか」あるいは「1行あたりのバイト数はいくつか」という意味になります。
画像の横幅が4の倍数でない場合、例えば5ピクセルだったとすると、作成したいビットマップは横幅5ピクセルですが、展開済みの byte[] 配列には
横8ピクセル分のデータが詰まっていることになります。
そこで、このストライド値を使用して、「次の行は 8 x 4 = 32バイト先から始まるよ」」ということを指定してあげます。

今回の場合、 physicalWidth * 4 を指定すれば良いでしょう。

unsafe {
    fixed (byte* ptr = &pixels[0]) {
        var bmp = new System.Drawing.Bitmap(width, height, physicalWidth * 4, PixelFormat.Format32bppArgb, new IntPtr(ptr));
        return bmp;
    }
}

ストライド値を使用したコンストラクタを使用するには、 byte[] 配列を IntPtr というポインタの形にして渡さなければなりませんので、unsafeコードが使えるようにコンパイラオプションを変更します。

f:id:sgtech:20161222105256p:plain

こうして得られた System.Drawing.Bitmap インタンスは、そのままPictureBoxなどに表示することができます。

テストしてみましょう

DDSファイルを何度かドロップしていると、何やら例外が発生してしまいました。

f:id:sgtech:20161222105214p:plain
f:id:sgtech:20161222105216p:plain

どうやら、上記の方法で作成したBitmapは、うまく取り扱えない場合があるようです。
ネットで検索したところ、fixed で固定したポインタを使用して作成したBitmapは、そのメモリがガベージコレクタによって移動されないことが前提となっているようです。
ストライド値が指定でき便利だったのですが、このままでは普通に使うことができず不便なので、ひと工夫しましょう。

簡単に解決する方法の1つは、fixed スコープから出ないうちに、別のビットマップにコピーしてしまうことです。
これで例外は起きなくなりました。

がしかし、そもそも作成したいビットマップの幅とストライドが同じ場合は、ストライドを指定するバージョンのコンストラクタを使用する必要はないわけですので、それを判定し、ごく一般的な方法でビットマップを作成します。
処理が少し複雑になりますので、メソッドにしてしまいましょう。

public static Bitmap CreateBitmap(byte[] pixels, int width, int height, int physicalWidth)
{
    if (width == physicalWidth) {
        // 作成したいビットマップの幅とストライドが同じ場合
        var bmp = new Bitmap(width, height, PixelFormat.Format32bppArgb);
        var bd = bmp.LockBits(new Rectangle(0, 0, width, height), ImageLockMode.WriteOnly, bmp.PixelFormat);
        System.Runtime.InteropServices.Marshal.Copy(pixels, 0, bd.Scan0, physicalWidth * 4 * height);
        bmp.UnlockBits(bd);
        return bmp;
    } else {
        // 作成したいビットマップの幅とストライドが異なる場合
        unsafe
        {
            fixed (byte* ptr = &pixels[0]) {
                // アンマネージドポインタを利用して作成したBitmapは fixed スコープ外に出るとAccess Violationを引き起こす
                // そこで、別のBitmapにコピーしてそれを返す。元のBitmapは不要なので usingを使用してDisposeする。
                using (var tmpbmp = new Bitmap(width, height, physicalWidth * 4, PixelFormat.Format32bppArgb, new IntPtr(ptr))) {
                    var bmp = new Bitmap(tmpbmp);
                    return bmp;
                }
            }
        }
    }

さて、これでいちおう完成となりました!
テストプログラムも改良し、情報を表示するようにもしています。

f:id:sgtech:20161222105258p:plain

BC1形式にも対応する

ヘッダ解析部分の簡単な拡張で、 DirectX10以降の新しい形式である「BC1形式」に対応することも可能ですので、一緒にやってしまいましょう。
このBC1形式のピクセル格納形式はDXT1と同じであるため、ピクセルオフセットを変更するだけで先程のDXT1Decoder.CreateBitmapメソッドで展開できてしまいます。

DirectX10形式のDDSファイルは、ヘッダのサイズが20バイト増え、148バイトになっています。従ってピクセルデータの開始オフセットもそれだけ後ろにずれることになります。

DecodeDXT()メソッドにピクセルオフセットを指定できるようにしておいたことが役に立った形となりました。

f:id:sgtech:20161222105300p:plain

この内容に従って、BC1の判定部分を追加しましょう。

switch (info.Format) {
    case "DX10":
        info.PixelOffset = HeaderSize + 20;    // DX10形式ではヘッダサイズが異なり、ピクセル開始位置もずれます。
        {
            // DX10形式であることが確認できたら、さらにBC1形式かどうかを調べる。
            uint format = BinaryUtility.MakeUInt32(data, 0x80));
            switch (format) {
                case 0x46:    // BC1_TYPELESS
                case 0x47:    // BC1_UNORM
                case 0x48:    // BC1_UNORM_SRGB
                    info.Format = "BC1";
                    break;
                default:
                    return info;
            }
        }
        break;
    default:
        return info;
}

:
:

    case "DXT1":
    case "BC1":
        return DXT1Decoder.CreateBitmap(data, info.Width, info.Height, info.PixelOffset);

最後に

さて、いかがでしたでしょうか?
今回の内容で、 DXT1形式、BC1形式のDDSファイルを System.Drawing.Bitmap に変換することができました。

この応用で、DXT3,DXT5,BC2,BC3,BC4,BC5といった圧縮形式への対応も、比較的簡単にできるのではと思います。
今後また機会がありましたら、次はぐっと複雑な、BC7形式を展開するコードの説明ができればと思っております。

なお、今回の記事を執筆するにあたり、多くのサイトを参考にさせていただきました。この場を借りてお礼を申し上げます。

それでは次回の更新をお楽しみに!

Unityでアニメーションのキーフレームリダクション

セガゲームス 開発技術部 プログラマーの樽美です。

普段は弊社独自の3D中間データ構造である「HND」の仕様策定やSDK作成、それに関連するツールの作成、
データパイプラインの整備、またSoftimageやMayaなどDCCツールのプラグインなどを作成したりしています。
最近ではUnityを触ることが多いです。

今回このブログを担当させていただくことになりました。
よろしくお願いいたします。


早速ですが、どんなネタを記事にすればよいのかいろいろ考えた末、割と役に立ちそうである
Unityでのアニメーションのキーフレームリダクションを行った時のことについて書いていきたいと思います。

動機

UnityでFBXファイルをインポートすると、DCCツールでFBXをBakeせずに出力しても、
インポート時にBakeされたアニメーションになるようです。

そして、UnityのFBXインポートオプションのAnimationsで「Keyframe Reduction」や
「Keyframe Reduction and Compression」を選択しても、
元のデータより打ってあるキーが増えてしまうことがあります。


Maya上でのキー
f:id:sgtech:20161118213448p:plain

Unityで「Keyframe Reduction」を指定してインポートした時のキー
f:id:sgtech:20161118213449p:plainf:id:sgtech:20161118213450p:plain
画像では少し見難いですが、X,Y,Zそれぞれで13個のキーに増えてしまっています。
※Xのカーブが上下反転しているのはMayaとUnityで座標系が異なるためです。

この増えてしまったキーをUnity上で削除しても特に問題はないようで、
Unity的にポジションやスケールなどは、X,Y,Zの3軸が揃っていなければならない訳ではなさそうです。


ローテーションについてはUnity内部でクオータニオンに変換されているようなので、
キーが3軸が揃ってしまうのは致し方ないかと思いますが、ポジションやスケールなど、
さらにキーを削除できる余地があるとなれば、ランタイムで使用するメモリ量の削減や
アセットファイルのデータ量削減などが期待できるので、出来れば不要なキーを削除したいですよね。


とはいえ、不要なキーを削るにしてもどのようにすればよいのでしょうか。
自分はひとまずこのように考えてみました。

はじめに

FBXをUnityで読み込むと、アニメーションは読み取り専用になっていますので、
これをduplicate(Ctrl+D)し、編集可能なAnimationにします。
f:id:sgtech:20161118213451p:plainf:id:sgtech:20161118213452p:plain
このduplicateされたAnimationファイルに対しキーフレームリダクションを行います。

キーフレームリダクションを行ってみる

その方法ですがまず、最初と最後のキーを補間した値とその間にあるキーの値を比較し、
その差が一番大きい位置のキーを残します。

その次に最初と残したキーを補間した値と、その間にあるキーの値を比較というように
これを順次繰り返し、必要なキーを残していくようにしました。
f:id:sgtech:20161118213515p:plain
f:id:sgtech:20161118213516p:plain
もう一つの例を見てみます。
f:id:sgtech:20161118213518p:plain
f:id:sgtech:20161118213519p:plain
f:id:sgtech:20161118213520p:plain
上記の例を見てみると必要なキーと不要となるキーの判別が上手くいっているように思えるのですが、
これだけでは確実に不要なキーを消すことや、必要なキーを残すことができませんでした。


以下の場合を見てみます。

f:id:sgtech:20161118213548p:plain
これまで通り、最初と最後のキーから補間した値と、その間にあるキーの値を比較し、
一番値の差が大きいキーをピックアップします。


f:id:sgtech:20161118213550p:plain
そこからまた次に残すキーをピックアップします。


f:id:sgtech:20161118213551p:plain
ここでもともと打たれていなかったキーで、本来ならば削除したいキーなのですが、
一番値が離れているのでピックアップされてしまいます。


f:id:sgtech:20161118213552p:plain
さらに次にも削除したいキーがピックアップされてしまいます。
このままピックアップされたキーを適用してしまうと理想とするMayaと同じキーの結果を得ることができません。


これらの問題を解決するために、以下のロジックを追加しました。

f:id:sgtech:20161118213553p:plain
ピックアップされてきたキーの値と前後のキーから補間した値を比較し、その差が大きい場合はキーを必ず残します。
またその際にはピックアップされてきたキーの場所のみ調べるのではなく、前後のキーの間も調べます。
(※ハンドルの傾きによってたまたま値が一致している場合があるため。)

そうでない場合は、残すかもしれないキーとして一時保留しておきます。


これで大体は判別が出来るようになりましたが、これだけではまだピックアップされたキーが
(しきい値にもよりますが)補間された値に近い場合に、不要なキーとして判別されてしまうことがありました。
f:id:sgtech:20161118213628p:plain

そのため最初に判定を行った後に、補間した値とすべてのキーが同じであった場合、
そのキーと次のキー間の値を比較するようにしました。
f:id:sgtech:20161118213630p:plain

そして一通りのキーが調査出来たら、残すことが確定しているキーと一時保留しているキーのみで
順次チェックを行い、一時保留しているキーが本当に必要であるかを判別します。
f:id:sgtech:20161118213632p:plain
f:id:sgtech:20161118213633p:plain
f:id:sgtech:20161118213635p:plain
f:id:sgtech:20161118213700p:plain
f:id:sgtech:20161118213701p:plain
だいぶいい感じにキーフレームのリダクションが行えました。

しかしここにきてこれまでの事を振り返ってみて、ふと思いました。
実はピックアップすることなど最初から必要なく、
順次キーが必要かどうかを判定していくだけでよかったのではないかと。


早速、試してみました。
f:id:sgtech:20161118213703g:plain
なんということでしょう!
思ったとおりでした(汗)

この方法では最初の方法よりも記述するコードがはるかに少なく、
しかもフルキーのデータに対しても良い結果が得られました。


最終結果です
f:id:sgtech:20161118213712p:plainf:id:sgtech:20161118213713p:plain
今回の例では、Maya上で作成したアニメーションのキーの数と全く同じにすることが出来ました。

コード一式

今回作成したコードはAnimationClipファイルの右クリックメニューから呼び出す方法で実装してみました。
そしてAnimationClipファイル内のすべてのAnimationCurveに対してキーリダクションを行っています。
また、同じ値とするかどうかのしきい値は0.0001fで固定にしています。

※以下のコードはあくまでサンプルコードです。Unity Version 5.4.0f3での動作を確認。

using System.IO;
using System.Linq;
using UnityEngine;
using UnityEditor;
using System.Collections.Generic;

public class KeyReductionProcessor : AssetPostprocessor
{
    [MenuItem ("Assets/Key Reduction")]
    static void KeyReduction()
    {
        Debug.Log ("Key Reduction...");
        foreach (UnityEngine.Object obj in Selection.GetFiltered(typeof(AnimationClip), SelectionMode.Editable))
        {
            string path = AssetDatabase.GetAssetPath(obj);
            // AssetPathよりAnimationClipの読み込み
            AnimationClip anim_clip = (AnimationClip)AssetDatabase.LoadAssetAtPath(path, typeof(AnimationClip));

            foreach (var binding in AnimationUtility.GetCurveBindings(anim_clip).ToArray())
            {
                // AnimationClipよりAnimationCurveを取得
                AnimationCurve curve = AnimationUtility.GetEditorCurve(anim_clip, binding);
                // キーリダクションを行う
                AnimationCurveKeyReduction(curve);
                // AnimationClipにキーリダクションを行ったAnimationCurveを設定
                AnimationUtility.SetEditorCurve(anim_clip, binding, curve);
            }
            // AnimationClip名の作成
            string anim_clip_name = Path.GetDirectoryName(path) + "/" + Path.GetFileNameWithoutExtension(path);
            // AnimationClipファイルの書き出し
            WriteAnimationCurve(anim_clip, anim_clip_name);
        }
    }

    // AnimationClipファイルの書き出し
    static private void WriteAnimationCurve(AnimationClip anim_clip, string anim_clip_name)
    {
        string tmp_name = anim_clip_name + "_tmp.anim"; // テンポラリファイル名
        // AnimationClipのコピーを作成
        var copyClip = Object.Instantiate(anim_clip);
        // テンポラリAnimationClipファイルの作成
        AssetDatabase.CreateAsset(copyClip, tmp_name);
        // テンポラリファイルから移し替え
        FileUtil.ReplaceFile(tmp_name, anim_clip_name + ".anim"); // コピー先ファイルがなければ自動で生成される。
        // テンポラリAnimationClipファイルの削除
        AssetDatabase.DeleteAsset(tmp_name);
        // データベースの更新
        AssetDatabase.Refresh();
    }

    // 2つのキーから、指定した時間の値を取得する
    static private float GetValueFromTime(Keyframe key1, Keyframe key2, float time)
    {
        float t;
        float a,b,c;
        float kd,vd;

        if (key1.outTangent == Mathf.Infinity) return key1.value; // コンスタント値

        kd = key2.time - key1.time;
        vd = key2.value - key1.value;
        t = (time - key1.time) / kd;

        a = -2 * vd + kd * (key1.outTangent + key2.inTangent);
        b =  3 * vd - kd * (2 * key1.outTangent + key2.inTangent);
        c = kd * key1.outTangent;

        return key1.value + t * (t * (a * t + b) + c);
    }

    // 指定したキーの値はKey1とKey2から得られた補間値と同じ値であるかを調べる
    static private bool IsInterpolationValue(Keyframe key1, Keyframe key2, Keyframe comp, float eps)
    {
        // 調査するキーのある位置
        var val1 = GetValueFromTime(key1, key2, comp.time);

        // 元の値からの差分の絶対値がしきい値以下であるか?
        if (eps < System.Math.Abs(comp.value - val1)) return false;

        // key1からcompの間
        var time = key1.time + (comp.time - key1.time) * 0.5f;
        val1 = GetValueFromTime(key1, comp, time);
        var val2 = GetValueFromTime(key1, key2, time);

        // 差分の絶対値がしきい値以下であるか?
        return (System.Math.Abs(val2 - val1) <= eps) ? true : false;
    }

    // 削除するインデックスリストの取得する。keysは3つ以上の配列
    static public IEnumerable<int> GetDeleteKeyIndex(Keyframe[] keys, float eps)
    {
        for (int s_idx = 0, i = 1; i < keys.Length-1; i++) {
            // 前後のキーから補間した値と、カレントのキーの値を比較
            if (IsInterpolationValue(keys[s_idx], keys[i+1], keys[i], eps)) {
                yield return i; // 削除するインデックスを追加
            }
            else {
                s_idx = i; // 次の先頭インデックスに設定
            }
        }
    }

    // 入力されたAnimationCurveのキーリダクションを行う
    static public void AnimationCurveKeyReduction(AnimationCurve in_curve, float eps = 0.0001f)
    {
        if (in_curve.keys.Length <= 2) return; // Reductionの必要なし

        // 削除インデックスリストの取得
        var del_indexes = GetDeleteKeyIndex(in_curve.keys, eps).ToArray();

        // 不要なキーを削除する
        foreach (var del_idx in del_indexes.Reverse()) in_curve.RemoveKey(del_idx);
    }
}

ファイルへの書き出し

次にこのキーフレームリダクションした結果をファイルに出力しなければなりませんが、
何も考えずに既存のAnimationClipファイルに対し上書きすると、
それまで利用していたGameObjectとのリンクが途絶えてしまいます。
f:id:sgtech:20161118213725p:plain

これをを防ぐためには、一度テンポラリのAnimationClipファイルを作成し、
既存のAnimationClipファイルに対してReplaceFile()を行えば、リンクが途絶えることがないようです。

真面目に行うならユニークなファイル名を生成するところですが、
今回はベースとなる名前の後ろに”_tmp”を付随しているだけです。

余談ですがReplaceFile()でファイルを更新すれば、AnimationClipファイルだけではなく
他のアセットファイルやprefabファイルの更新の時にMissingになってしまうことも防げるようです。

しかしここでまた衝撃的な事実が。
キーフレームリダクションを行い、キーの数は確実に減っているのですが、そのデータをファイルに書き出すと
元のAnimationClipファイルよりファイルサイズが大きくなってしまうことがありました。

特に「Keyframe Reduction and Compression」を指定してインポートしたデータの場合と比べると
キーの数が少なくなっているはずなのにファイルサイズがかなり大きくなってしまいました。

何が原因なのかとリダクションする前のAnimationClipファイルを開いてみると
m_EditorCurves:の項目が出力されない事があるためにファイルサイズが小さく済んでいるようです。
(※出力される場合もあり、この辺りのUnityの挙動がよく分かっていません。)

UnityのAPIのAssetDatabase.CreateAsset()でファイルを作成したときにもこのファイルフォーマットで
出力できればよいのですが、どうも現状ではその方法はなさそうです。


と、その前にX,Y,Zそれぞれをキーリダクションしてもファイルに出力すると必ず3軸揃った
データも出力されていることがわかりました。

  m_PositionCurves:
  - curve:
      serializedVersion: 2
      m_Curve:
      - time: 0
        value: {x: -0, y: 0, z: 0}
        inSlope: {x: -0.00000008580087, y: 0.061224494, z: 0.0000000031700533}
        outSlope: {x: -0.00000008580087, y: 0.061224494, z: 0.0000000031700533}
        tangentMode: 0

※出力したAnimationClipファイルより一部抜粋

X,Y,Zすべて削除している場合には、確かにその時間のキーは無くなってはいるのですが、
ひとつでもキーが残っていると結局3軸分出力されるようで、なんかちょっとショックです。

まとめ

始めに考えたコードはまったく無用のものになってしまい、だいぶ遠回りをしてしまいましたが、
キーリダクションそのものについては大体良い結果が得られるようになったと思います。
しかし、ファイルサイズ削減についてはインポートした結果よりデータが増えてしまう事があるという残念な結果に。
それでも、あれこれ試行錯誤している時はとても楽しかったです。
カーブによってはこのロジックだけでは対応しきれないかもしれませんが、
この記事がこれからアニメーションのキーフレームリダクションを行いたいと思われる方の参考になれば幸いです。


それでは次回の更新をお楽しみに。

モダンな OpenGL で頂点モーフ

セガゲームス、開発技術部の山田@プログラマです。
普段はゲームのランタイムライブラリ作成やらツール作成やら、深追いデバッグを担当しています。
今回より本ブログにデビューとなりましたのでよろしくお願いします。

さてさて今回のお題に入りましょう。
最近は DirectX12 や Vulkan といった新しい API がありますが、今回は OpenGL の比較的新しい機能の紹介をしたいと思います。

お題: Shader Storage Buffer Object を使う

OpenGL 4.3 で入った Shader Storage Buffer Object (SSBO) というものを使ったことはありますか?

この SSBO は知名度が低いのですが、シェーダーから自由に読み書きができるという点ですごく便利です。
シェーダーにバッファをセットするという点では Uniform Buffer Object (UBO, 定数バッファ) がありますが、
これは読み込みしかできません。書込みが可能な SSBO を使って何ができるでしょうか。

1つの例として今回は SSBO を用いての頂点モーフを考えてみましょう。
近年の描画処理においてはモデルデータを1回描画して終わりではありません。
複数回同じモデルを色々なシェーダーで描画して、最終的な画面結果を作り上げていきます。
そのため、頂点モーフの処理もまた複数回必要になってきます。
しかしながら形状自体は同じであるため、頂点モーフの結果を使いまわせれば処理コストを節約することが出来そうです。
以降はその実装についてのご紹介です。

SSBO のチュートリアル

SSBO は以下のようにして生成します。 OpenGL におけるバッファオブジェクトの生成方法と同じです。

GLuint ssbo;
glGenBuffers(1, &ssbo);
glBindBuffer(GL_SHADER_STORAGE_BUFFER, ssbo );
glBufferData(GL_SHADER_STORAGE_BUFFER, bufferSize, buffer, GL_STATIC_DRAW );

使う際には、この作成した SSBO のオブジェクトを glBindBufferBase で任意のバインディングポイントにセットしていきます。
バインディングポイントの最大数は GPU のドライバに依存するようなので若干注意が必要です。

glBindBufferBase( GL_SHADER_STORAGE_BUFFER, bindingPoint, ssbo );

こうやってセットしたバッファを シェーダーから使うには以下のように記述します。
不定の長さのものを扱えるところも SSBO の扱いやすいポイントです。

#version 430
layout(location=0) in vec4 position;

layout(std430,binding=0) readonly buffer SSBO {
  vec4 data[];
} gSSBO;

void main() {
   vec4 data0 = gSSBO.data[0];
   vec4 data1 = gSSBO.data[2];
}

頂点モーフの実装

SSBO の使い方を分かったところで、頂点モーフの実装を考えてみます。
複数のモーフターゲットがあることが普通で、処理できる個数を限定してしまうことはしたくありません。
そこで各パラメータによって複数のモーフターゲットの成分を合成し、
描画する際にはこの成分と加算することで形状が確定するということにしてしまいます。
この成分の合成する先として SSBO を使います。

f:id:sgtech:20161027134536p:plain:w300

描画のための OpenGL の呼び出しコードは以下のようなものとなります。
各頂点分だけのモーフ成分を計算したいので、 GL_POINTS で頂点分を処理しているところがポイントでしょうか。
ピクセルとしての書き込みを行う必要もないので GL_RASTERIZER_DISCARD を有効化していたりするのもポイントですね。

glUseProgram( ssboMorphShader );
glBindVertexArray( ssboMorphVAO );
glEnable(GL_RASTERIZER_DISCARD);
glDrawArrays(GL_POINTS, 0, vertexCount );

glDisable(GL_RASTERIZER_DISCARD);
glUseProgram( drawMorphShader )
glBindVertexArray( drawVAO );
glDrawElements( GL_TRIANGLES, indexCount, GL_UNSIGNED_SHORT, nullptr );

実行してみると以下のようになりました。

素直に実装したマルチストリームの頂点シェーダー版と比べて、
数回描画する場合には SSBO を使って処理したほうが高速に処理できることが確認できます。

f:id:sgtech:20161027134340g:plain

One more thing

さて、頂点モーフターゲットの成分を合成している部分について注目してみると、
テクスチャ未使用、ピクセルシェーダーも不要と、単なる計算処理しかしていません。
この部分について OpenGL にも搭載されている ComputeShader (以降 CS) を使ってみることにしましょう。

モーフターゲットについては SSBO で作成しているので、そのまま CS にセットできます。
また、今回も SSBO に結果を書き込むため、描画処理そのものは先ほどのものと共通で使えます。

計算処理から描画までの OpenGL の呼び出しコードは以下のようなものになります。
モーフの計算部分については Vertex Array Object (以降 VAO) のセットが不要です。

glUseProgram( computeMorphShader );
glDispatchCompute( vertexCount/32+1, 1,1);

glUseProgram( drawMorphShader )
glBindVertexArray( drawVAO );
glDrawElements( GL_TRIANGLES, indexCount, GL_UNSIGNED_SHORT, nullptr );

同じ SSBO を使ったコードで、頂点シェーダー版と ComputeShader 版とで、
実行について速度差が生まれるかどうかについてですが、今回の例においては劇的な差はありませんでした。
若干 頂点シェーダーSSBO出力版のほうが軽いでしょうか。

f:id:sgtech:20161027134503g:plain:w400

最後に

SSBO を用いた頂点モーフの実装例を紹介しました。
勢い余って OpenGL の Compute Shader による実装までやってしまいましたが、いかがでしたでしょうか。
OpenGL 4.x もまだまだ面白いと感じてもらえたら幸いです。




モデル協力:セガ・ハード・ガールズ


セガ・ハード・ガールズ公式 HP:http://shg.sega.jp/ 公式twitter:アソビン教授(セガ・ハード・ガールズ) @SHG_Official TVアニメ『Hi☆sCoool! セハガール』公式HP: http://shg.sega.jp/anime.html

(C)SEGA /セハガガ学園理事会


Powered by はてなブログ