Hatena::ブログ(Diary)

Ko-Taのバ・ー・ルのようなもの

2017-10-25

spine morph-target

本家より良い返事が貰えたので、経緯を残しておきます。そのうち使えるようになるそうです。ありがたし!


ver3.6のspineを弄っているとメッシュデフォームがあるものの、口パクや表情でよく使うモーフターゲット(morph-target)がないので、固定的なものは力業でなんとかなるとしても、動的な表情変化などの実現が厳しいなぁと気づくと思います。

なんとかしたいですよねナナチ。

幸いランタイムのソースコードがフルオープンなので中を覗けますし、(ライセンスの範囲内で)勝手に弄ってもOKなので、なんとかできないかなーと休日眺めてみました。

サンプル

f:id:Ko-Ta:20171031234005p:image

どういったものかはサンプルで。

  • spine-c morph-target customize sample

https://1drv.ms/u/s!AvtcMsC8irYBgzJkqmizB2me52T1

まず過去ログ

フォーラムの過去ログ(英語)を眺めてみたら、過去に2度ほど話題には上がったみたいです。

少なからず同じような悩みはあったみたいですね。

まずモーフターゲットが実装できるか

モーフターゲットを実現させるにはいくつかの条件が必要です。

  • 変形前のベースとなる形状が必要

名前の通りベースとなる”ターゲット”が必要になります。これは変形前の形状で、変形後との差分の計算に使用されます。

差分はそのままベースに加算されていき、1つのオブジェクトに変形が複数ミックスされていきます。

diff? = Animation? - base
out = base + diffA + diffB + diffC + diffD ...

みたいな感じです。

幸いspineにはベースが存在します。エディタではSETUPとして部品を組み立てるモードがありますね。あれです。あれを使いましょう。

プログラム上だとちょっとわかりにくいですがskeletonから追跡できます。Animation.cを見れば手っ取り早いかなと思います。

  • 変形後の形状もベースと同じ構造をしていること

これはSETUPから変形させて作られたアニメーションのことを指します。

モーフターゲットの場合はベースと構造が同じでなくてはいけません。メッシュなら頂点数とかポリゴンの張り方とか。

幸いspineも同じガイドラインを敷いているので問題無さそうです。

  • それら複数の形状を指定できる環境

複数合成するのでその土壌がなければいけません。幸いアニメーションはトラックという概念で複数扱えるので問題ありません。

  • ということで

エディタ上では難しいけど、ランタイムのアニメーショントラックに仕込めば実現できそうです。

エディタだとプレビューのあそこですね。タイムラインではありません。

実装

spine-c ver3.6を使用します。実装の大半はAnimation.cになります。

「void _spDeformTimeline_apply」あたりを見てみると分かりやすいと思います。

前半と中盤は定義キーが無い前と後ろの処理、要するに例外処理なので飛ばします。

一番最後あたりにアニメーションの合成にあたるコードがあります。

for (i = 0; i < vertexCount; i++) {
      float prev = prevVertices[i];
      float v = prev + (nextVertices[i] - prev) * percent;
      vertices[i] += (v - vertices[i]) * alpha;
}

prevVerticesが現在位置より後方の変形情報、nextVerticesが現在位置より前方の変形情報です。

percentがキー間のブレンド率なのでvが現在地点での変形座標。alphaがモーションのブレンド率なのでアニメーション間の合成だけ抜き出せば

out = out + (v - out) * alpha

と単純な構造です。ここをモーフターゲットに改良するだけです。


モーフターゲットはベースとなる変形前の座標が必要になります。

spineでいえばSETUPですが、このコードの上あたりを見ると―

case SP_MIX_POSE_SETUP:
    if (!vertexAttachment->bones) {
        memcpy(vertices, vertexAttachment->vertices, vertexCount * sizeof(float));
    } else {
        for (i = 0; i < vertexCount; i++) vertices[i] = 0;
    }
...

まんまがあります。

「vertexAttachment->vertices」がSETUP、変形前のベース情報になります。

SP_MIX_POSE_SETUPは最初に投げられるもので、変形前の状態を変形バッファにコピーしてる感じです。


これで必要な要素は揃いました。

あとは以下の公式に変形すればいいだけです。

out = out + (A - SETUP) * alpha

なので

float* setupVertices = vertexAttachment->vertices;
for (i = 0; i < vertexCount; i++) {
    float prev = prevVertices[i];
    float v = prev + (nextVertices[i] - prev) * percent;
    vertices[i] += (v - setupVertices[i]) * alpha;
}

となります。おわり。


本当はもうちょっと複雑

アルゴリズムの話は以上でおしまいですが、実際はもうちょっと複雑です。計算が複雑というわけではなく、例外処理、最適化処理との相性、言語的な部分などなど。

今までアニメーションは上書きだったので、alpha1.0だとそれより前のアニメーション処理要らないですね!なども組み込まれているので対処が必要です。

実際に組み込むには20カ所ほど変更や修正が必要です。

実装されるまで待ちましょう :Q

2017-09-05

spine c++ serialize

たぶん必要になるでしょうアニメーションのシリアライズ、保存、復元です。ただし、ゆるーいシリアライズです(後記)。

spineにはアニメーションを保存する機能が、ありそうでありません。作らないといけません。付けて欲しいなぁ…。

spineのアニメーション

アニメーションはトラックという概念で管理されています。エディタだと「preview」機能で確認できます。

[優先↑]

track2:[何もしない][瞬き][何もしない][瞬き]
track1:[狙う]
track0:[歩く][走る]
base  :[setup状態]

横方向が単純なモーションの切り替わりです。上の例では歩きから走るモーションに切り替わります。切り替わるタイミングは「delay」で指定することが出来、0(指定無し)だとループポイントで切り替わります。アニメーションを作成する際はループポイントで繋がるように作りましょうって感じみたいです。もちろん切り替わりが分からないようにミックス(ブレンド)されます。

で、縦方向がtrack(トラック)の概念です。難しいものではありません、単にキーフレームが上書きされるだけです。キーフレームが無い箇所(ボーンやスキニングポイント)はそのまま通過、歩いているアニメーションが使用されます。

こんな感じで、部分部分のアニメーションを上書きさせていって、ゲームで必要な動的なアニメーションをプログラムから制御しましょうというのがtrackです。

なお、trackが何も無い状態だと、エディタにおける setup 状態となります。


頑張ればlive2Dみたいなことも出来そうですが、顔を左右に動かすようなものをプログラムからブレンドさせてコントロールするのは現状無理があるので、そこが利用用途が異なる理由の1つでしょう。決まったアニメーションには強いのですが。

(でもソースを見てるとちょっと弄れば出来そうなので暇が出来たらまとめたいと思います)

データ構造

アニメーションを保存するには spAnimationState の情報を使用します。

  • spAnimationState
struct spAnimationState {
	spAnimationStateData* const data;

	int tracksCount;
	spTrackEntry** tracks;

	spAnimationStateListener listener;

	float timeScale;

	spTrackEntryArray* mixingTo;
	void* rendererObject;
};

trackscount,tracks が上で説明したアニメーションの構造そのままです。ポインタのポインタ(**)がきたら9割は「あー配列かぁ」なので恐れないでください。trackscount分だけ構造体のポインタが並んでいるだけです。timescaleは使うかも知れませんね。保存して復元させると良いでしょう。

あとは復元には不要なので省略します。


次にtrackの情報 spTrackEntry について。

  • spTrackEntry
struct spTrackEntry {
	spAnimation* animation;
	spTrackEntry* next;
	spTrackEntry* mixingFrom;
	spAnimationStateListener listener;
	int trackIndex;
	int /*boolean*/ loop;
	float eventThreshold, attachmentThreshold, drawOrderThreshold;
	float animationStart, animationEnd, animationLast, nextAnimationLast;
	float delay, trackTime, trackLast, nextTrackLast, trackEnd, timeScale;
	float alpha, mixTime, mixDuration, interruptAlpha, totalAlpha;
	spIntArray* timelineData;
	spTrackEntryArray* timelineDipMix;
	float* timelinesRotation;
	int timelinesRotationCount;
	void* rendererObject;
	void* userData;
};

色々ありますが、9割ぐらいはアニメーションをセットした際に設定される値なので省略しちゃっても良いかなと思います。厳密にやり出すと前のアニメーションとのブレンドミックスなどもあるので相当大変です。今回はそこまでやりませんし、そこまで必要ないと思いますし。

必要そうなのは、現在のアニメーション名を知るための animation、次のアニメーションを知るための next、再生時間関連の tracktime,delay,timescale あたりでしょうか。alphaも弄るなら要りそうです(このalphaは色では無く、trackごとのアニメーションのブレンド率です)。


名前取得するアニメーションデータの構造体 spAnimation は簡単です。

  • spAnimation
typedef struct spAnimation {
	const char* const name;
	float duration;

	int timelinesCount;
	spTimeline** timelines;
} spAnimation;

これはデータなので、弄ってはいけません。動的な情報を管理する state ではありません。

name だけ拾えば良いですね。

duration はこのアニメーションの1ループの長さです。1.0で1secの単位で表されていますが、これは今回不要です。

シリアライズ、保存

主に spTrackEntry の情報を保存すれば、復元は出来そうです。trackごとにアニメーションが何個並んでいて、それぞれがどのような状態なのかを記憶していきます。簡単に言えば2次元ループ処理ですね。

  • _savestate
void _savestate(spAnimationState* animation,const std::string &filename) {
	std::stringstream* ss = new std::stringstream();
	int count = animation->tracksCount;

	*ss << "trackcount " << count << std::endl;
	for (int i = 0; i < count; i++) {
		if (animation->tracks[i] == nullptr) {
			*ss << "exists 0" << std::endl;
		}
		else {
			*ss << "exists 1" << std::endl;
			_savestate_tracks(ss, animation->tracks[i]);
		}
	}
	*ss << "[eof]";

	_writestringfile(ss, filename);  //stringstreamを保存する関数

	delete ss;
}

状態保存は構造体にして保存したほうが良いかなと思いましたが、テキストの方が見易いので stringstream 使っておきます。実際組む場合はもうちょっと賢いjsonなどに出力させた方がいいでしょう。

trackscount でループを回す際に注意点があります。アニメーションを track0,2 と指定した場合、track1 がnullになります。なので、nullチェックが必要です。ここでは「exists」がそれにあたります。存在する場合は、track の中のアニメーション情報 spTrackEntry の stringstream に追加していきます。

  • _savestate_tracks
void _savestate_tracks(std::stringstream* ss, spTrackEntry* track) {
	//entry animation count
	int count=1;
	{
		spTrackEntry* data = track;
		while (data->next != nullptr) {
			data = data->next;
			count++;
		}
		*ss << "animationcount " << count << std::endl;
	}
	//each animation satate (track entry)
	spTrackEntry* data = track;
	for (int i = 0; i < count; i++) {
		*ss << "name " << data->animation->name << std::endl;			// string
		*ss << "duration " << data->animation->duration << std::endl;	// animation time length
		*ss << "tracktime " << data->trackTime << std::endl;
		*ss << "timescale " << data->timeScale << std::endl;
		*ss << "loop " << data->loop << std::endl;						// int
		*ss << "delay " << data->delay << std::endl;
		//next
		data = data->next;
	}
}

横方向に当たるtrackないのアニメーションですが、数を知る場合は count が存在しないのでちょっと工夫が必要です。next で次の情報が取得出来る数珠つなぎで管理されているので、nextをくるくる遡ってカウントを数えます。nullがでたら終端です。

count が取得出来たら、あとは同じようにくるくる回して情報を保存していきます。

できあがったのがこちら。

  • savestate.txt
trackcount 3
  exists 1
  animationcount 2
    name walk
      duration 0.8667
      tracktime 0.333333
      timescale 1
      loop 1
      delay 0
    name run
      duration 0.8
      tracktime 0
      timescale 1
      loop 1
      delay 0.6667

  exists 0

  exists 1
  animationcount 1
    name aim
      duration 0
      tracktime 0.333333
      timescale 1
      loop 0
      delay 0

改行とスペースと空行は実際にはありません。分かりやすいように付けただけなので、実際のデータとは異なります。

デシリアライズ・復元

この情報から復元します。まっさらなモーション状態 spAnimationState_clearTracks にして、そこから再構築します。

コードもシリアライズとほぼ同じ構造なので、理解は簡単でしょう。

「>> name >> exists」のような構文は、値の読み込みです。便利ですが手抜きです:)

  • _loadstate
void _loadstate(spAnimationState* animation, const std::string &filename) {
	std::stringstream* ss = _readstringfile(filename); // stringstreamにファイル読み込みます
	std::string name;
	int count;
	*ss >> name >> count;
	printf("%s:%d\r\n", name.c_str(),count);

	for (int i = 0; i < count; i++){
		int exists;
		*ss >> name >> exists;
		if (exists == 0) {
			// null
		}
		else {
			_loadstate_tracks(ss, animation, i);
		}
	}
	delete ss;
}

void _loadstate_tracks(std::stringstream* ss, spAnimationState* animation, int no) {
	// animation count
	std::string name;
	int count;
	*ss >> name >> count;

	// each animation
	for (int i = 0; i < count; i++) {
		// animation name
		std::string animationname;
		*ss >> name >> animationname;
		// duration (not use)
		float duration;
		*ss >> name >> duration;
		// tracktime
		float tracktime;
		*ss >> name >> tracktime;
		// timescale
		float timescale;
		*ss >> name >> timescale;
		// loop
		int loop;
		*ss >> name >> loop;
		// delay
		float delay;
		*ss >> name >> delay;

		spTrackEntry* entry;
		if (i == 0) {
			entry = spAnimationState_setAnimationByName(animation, no, animationname.c_str(), loop);
		}
		else {
			entry = spAnimationState_addAnimationByName(animation, no, animationname.c_str(), loop, delay);
		}
		entry->trackTime = tracktime;
		entry->timeScale = timescale;
		//printf("animation:%s time:%f delay:%f loop:%d\r\n", animationname.c_str(), tracktime, delay, loop);
	}
}

モーションを構築する際に一応 setAnimation と addAnimation を使い分けてあります。全部 add で良いような気もしますが、set のほうがtrackにあるモーションをすべて破棄して設定するとあるので、安全かな?と思います。

実は stringstream の特性でアニメーション名にスペースがあると動きません。実装の際は string convertspaceword(const string) みたいな関数で文字を置き換えて対処などしてください。

あと AnimationByName はサンプルなので使ってますが、無い名前を渡すとnull例外アクセスで死ぬので、ちゃんと自分で名前チェック入れるか自前でfix命令作って代用してくださいね。

実験

前回作ったポリゴン表示ログに細工して、出力ファイルが同じかどうか比較します。

//before
triangle:vector(824.137,932.741,728.086,745.995,547.565,838.845):argb=(1,1,1,1)
triangle:vector(547.565,838.845,643.616,1025.59,824.137,932.741):argb=(1,1,1,1)
triangle:vector(568.606,539.411,586.991,577.27,592.505,573.412):argb=(1,1,1,1)
//after
triangle:vector(824.138,932.741,728.086,745.995,547.565,838.845):argb=(1,1,1,1)
triangle:vector(547.565,838.845,643.616,1025.59,824.138,932.741):argb=(1,1,1,1)
triangle:vector(558.639,534.024,592.008,535.425,590.503,537.489):argb=(1,1,1,1)

あれ?ちょっと違う…。

いろいろ試した結果、spSkeleton_updateWorldTransform(ワールド座標などをスケルトンに反映)を抜いてみると

//before
triangle:vector(824.137,932.741,728.086,745.995,547.565,838.845):argb=(1,1,1,1)
triangle:vector(547.565,838.845,643.616,1025.59,824.137,932.741):argb=(1,1,1,1)
triangle:vector(568.606,539.411,586.991,577.27,592.505,573.412):argb=(1,1,1,1)
//after
triangle:vector(824.137,932.741,728.086,745.995,547.565,838.845):argb=(1,1,1,1)
triangle:vector(547.565,838.845,643.616,1025.59,824.137,932.741):argb=(1,1,1,1)
triangle:vector(568.606,539.411,586.991,577.27,592.505,573.412):argb=(1,1,1,1)

やったーおなじだー。

spSkeleton_updateWorldTransform のドキュメントを見ると「IKやら物理計算も反映させます」とあるので、それかなと思うのですが、サンプル「spineboy」には設定されてないような……。


これ以上は実際に表示させて詰めるしか無さそうです。

なお、復元直後に_savestateして保存したアニメーション情報を見てみると一緒だったので、アニメーション情報に関してはちゃんと復元されているようです。

忘れてました、emptyアニメーション

アニメーションをその状態で停止させる命令に EmptyAnimation というのがあります。これは特殊なアニメーションになるので、対応させるには上記にちょっと工夫が必要になります。


まず empty をセットすると trackentry の情報がどうなるのかを調べましょう。

spAnimationState_addEmptyAnimation(animationState, 0, 0.0f, 0.0f);  //mixDuration=0.0 delay=0.0

...
  name walk
...
  name <empty>
    duration 0
    tracktime 0
    timescale 1
    loop 0
    delay 0.7667
  name run
    duration 0.8
    tracktime 0
...

名前が <empty> になるのが特徴です。そのほかのパラメータは特に変わりはありません。

mixDurationは前のモーションとミックスブレンドするものだと思われます。停止させるのにミックス?よくわかりませんが、試しに1秒を指定してみると。

spAnimationState_addEmptyAnimation(animationState, 0, 1.0f, 0.0f);  //mixDuration=0.0 delay=0.0

...
  name walk
...
  name run
    duration 0.8
    tracktime 0
...

消えました。…なんで?

コードを追うと spAnimationState_update あたりで消滅しています。

最後に追加しないとだめよ!という事で最適化されたのかもしれませんが、コードを追ってもよく分からなかったので……見なかったことに。

emptyアニメーションの判断

このemptyアニメーションなんですが、trackentryから判別する手段がこれといって用意されているわけではありません。なので、自前で判別する必要があるのですが、大きく2パターンあります。


  • 名前から判別

entry->animation->name の名前が <empty> となるのが特徴です。

たぶんこの仕様は変わらないと思います。たぶん。(なおconstで名前が定義されてたりはしません)

くれぐれも自分が作ったモーションに <empty> なんて名前を付けてはいけません。ってマニュアルには書いてなかったので、世界中で数人ぐらいはこれで苦しんだ人も居たかも知れません。


  • animetionポインタのアドレス

もっとプログラム的に判別するなら AnimationState.c ファイルの戦闘を見るとemptyアニメーションがちゃっかり static されてます。

static spAnimation* SP_EMPTY_ANIMATION = 0;

いわゆるひとつのしんぐるとんで、entry->animation が SP_EMPTY_ANIMATION と同値であればemptyだと判断できます。

問題はこの SP_EMPTY_ANIMATION がexternされていないことなんです……。


というわけで、名前なりで判断して、loadstateのところを以下のように切り替えてあげましょう。

void _loadstate_tracks(std::stringstream* ss, spAnimationState* animation, int no) {
...省略...
		spTrackEntry* entry;
		if (i == 0) {
			if (animationname == "<empty>")
				entry = spAnimationState_setEmptyAnimation(animation, no, 0.0f);
			else
				entry = spAnimationState_setAnimationByName(animation, no, animationname.c_str(), loop);
		}
		else {
			if (animationname == "<empty>")
				entry = spAnimationState_addEmptyAnimation(animation, no, 0.0f, delay);
			else
				entry = spAnimationState_addAnimationByName(animation, no, animationname.c_str(), loop, delay);
		}
		entry->trackTime = tracktime;
		entry->timeScale = timescale;
...省略...

なお、delayの値がどうにも-999.7みたいな値を示すこともあるので、設定しないほうが良いのかも知れません。わからん。

2017-09-01

Netjs

Netjsもduocodeと同じくC#からTypeScript(JS)へ変換するプロジェクトです。ちょっと試してみましょう。

インストール

  • Netjs

https://github.com/praeclarum/Netjs

これはduocodeほど環境を必要とされません。githubからcloneして、コンパイルしてexeとして使用します。

出力はTypeScriptなので、まだならその環境もインストールしておきましょう。

変換

c#のクラスモジュール、特にPCL(ポータブル・クラス・ライブラリ)が望ましいみたいです。

C#でDLLで吐かせた後、そのDLLからTypeScriptに変換します。

公式の通り

netjs Library.dll

でtsが出力されます。

これも mscorlib 依存ですが、そのファイルは一緒に出力されません。どこにあるのかというと、cloneしたもののトップディレクトに「mscorlib.ts」として存在しするので、適当にコピーして環境にもってきましょう。

コンパイルはバージョンの指定「-t ES5」が必要です。無いと怒られます。

tsc -t ES5 mscorlib.ts Library.ts --out out.js 

で、1つのファイルにまとめられて完了です。

duocodeと比べると「mscorlib.ts」のファイル容量がかなり少ないです。これはtsによるところもあるでしょうが、バージョンが古いみたいです。

例えば下記のようなクラスを変換してみたところ

	public class text
	{
		/**
		 * A to Z change low case.
		**/
		static public string lowercase(string s)
		{
			int len = s.Length;
			//StringBuilder sb = new StringBuilder(len);    // error unsupport capacity
			StringBuilder sb = new StringBuilder();

			for (int i = 0; i < len; i++)
			{
				char c = s[i];
				if ((c >= 'A') && (c <= 'Z')){
					c = (char)((int)c + 32);
				}
				sb.Append(c);
			}
			return sb.ToString();
		}
	}
....

StringBuilder Class は用意されていますが、StringBuilder.capacityが未実装で、変換後にエラーが出ました。

github も2年前に更新となっているので、長らくプロジェクトは停滞しているみたいです。


まとめ

duocodeに比べるとTypeScriptで出力されるのがとても良い感じなのですが、プロジェクトとしてはバージャンアップが望めないみたいなので他を当たるのが良さそうです。