spine-cppランタイムドキュメント

ライセンスについて

Spineランタイムをアプリケーションに組み込む前に、必ずSpine Runtimes Licenseを確認してください。

はじめに

spine-cppとは、C++とネイティブに連携できる言語で書かれたゲームエンジンやフレームワークにSpineのアニメーションを組み込むための汎用ランタイムです。

spine-cppは、以下の機能を提供します:

spine-cppランタイムはエンジンに依存しない汎用ランタイムなので、ユーザー自身が、エンジン固有のファイル入出力や画像のロードをspine-cppランタイムに提供するための関数群を実装し、spine-cppランタイムが生成したデータをエンジンの描画システムで描画し、さらにラグドールなどの高度なユースケースが要求される場合にはオプションとしてエンジンの物理システムとデータを統合する必要があります。

spine-cppランタイムは、幅広いプラットフォームとコンパイラとの互換性を保証するためにC++03で記述されています。

また、他の公式Spineランタイムはspine-cppをベースにしているため、お好みのエンジンとの統合のためのサンプルとして利用することができます:

以下の各セクションでは、spine-cppランタイムの概要とその使用方法について、エンジンに依存しない形で簡単に説明します。spine-cppをベースにした公式Spineランタイムのほとんどは、spine-cpp APIの一部を独自の使いやすいAPIにカプセル化しています。しかし、基盤となるspine-cppランタイムの基本を理解しておくことは有益です。

注意: 当ガイドでは、Spineで使用される基本的なランタイムのアーキテクチャと用語を理解されていることを前提としています。ランタイムのより高度な機能については、APIリファレンスも参照してください。

Spineアセットのspine-cpp向けエクスポート

以下の実行方法については、Spineユーザーガイド内で紹介されています :

  1. JSONまたはバイナリ形式でのスケルトン&アニメーションデータのエクスポート
  2. スケルトンの画像を含むテクスチャアトラスのエクスポート

スケルトンデータとテクスチャアトラスをエクスポートすると、以下のファイルが得られます :

  1. skeleton-name.jsonまたはskeleton-name.skel: これはスケルトンとアニメーションのデータを含んでいます。
  2. skeleton-name.atlas: これはテクスチャアトラスの情報を含んでいます。
  3. 1つまたは複数の.pngファイル: これはテクスチャアトラスの各ページで、スケルトンが使用するイメージを含んでいます。

補足: 拡張子が .skel のバイナリフォーマットの方が軽量でロードが速いです。

注意: スケルトンごとに1つのテクスチャアトラスを作成するのではなく、複数のスケルトンの画像を1つのテクスチャアトラスにパックすることもできます。詳しくはテクスチャパッキングのガイドをご覧ください。

Spineアセットのロード

spine-cppは、テクスチャアトラス、Spineスケルトンデータ(ボーン、スロット、アタッチメント、スキン、アニメーション)をロードし、AnimationStateDataによってアニメーション間のミックスタイムを定義するためのAPIを提供します。この3種類のデータはセットアップポーズデータとも呼ばれ、通常、一度ロードされた後はすべてのゲームオブジェクトで共有されます。この共有の仕組みは、各ゲームオブジェクトに独自のスケルトンとAnimationStateを与えることで実現されており、インスタンスデータとも呼ばれています。

補足: ローディングの全体的なアーキテクチャの詳細については、全般的なSpineランタイムドキュメントを参照してください。

テクスチャアトラスのロード

テクスチャアトラスデータは、アトラスページ内の個々の画像の位置を記述したカスタムアトラス形式で保存されます。アトラスページ自体はアトラスとは別にプレーンな.pngファイルとして保存されます。

Atlasクラスは、このタスクのために、ディスクからアトラスファイルをロードするコンストラクタと、rawインメモリデータからアトラスファイルをロードするコンストラクタを提供します。アトラスのロードに失敗した場合、デバッグモードでassertが発生します。

#include <spine/spine.h>

using namespace spine;

// ファイルからアトラスを読み込む。最後の引数はエンジン固有のもので、
// アトラスページのテクスチャをロードしてアトラスに格納します。
// このテクスチャはレンダリングコードによって、後で Atlas->getRendererObject() で取得されます。
TextureLoader* textureLoader = new MyEngineTextureLoader();
Atlas* atlas = new Atlas("myatlas.atlas", textureLoader);

// アトラスをメモリから読み込みます。メモリの場所、バイト数、
// アトラスページのテクスチャを読み込むディレクトリを相対的に指定します。
// 最後の引数はエンジン固有のもので、アトラスページのテクスチャを
// ロードしてアトラスに格納します。このテクスチャはレンダリングコードによって
// 後で Atlas->getRendererObject() で取得されます。
Atlas* atlas = new Atlas(atlasInMemory, atlasDataLengthInBytes, dir, textureLoader);

スケルトンデータのロード

スケルトンデータ (ボーン、スロット、コンストレイント、アタッチメント、スキン、アニメーション)は、ヒューマン・リーダブル(対人可読形式)なJSONまたはバイナリ形式でエクスポートできます。spine-cppはスケルトンデータを spSkeletonData クラスインスタンスに格納します。

JSON形式のエクスポートファイルからスケルトンデータを読み込むために、SkeletonJson インスタンスを作成し、先にロードした Atlas を受け取り、スケルトンのスケールを設定し、最後にファイルからスケルトンデータを読み込むようにします:

#include <spine/spine.h>

using namespace spine;

// ロードに使用するSkeletonJsonを作成(create)し、
// ロードしたデータが元データの2倍の大きさになるようにスケールを設定する
SkeletonJson json(atlas);
json.setScale(2);

// スケルトンの.jsonファイルをSkeletonDataに読み込む
SkeletonData* skeletonData = json.readSkeletonDataFile(filename);

// ロードに失敗した場合は、エラーを表示してアプリを終了する
if (!skeletonData) {
   printf("%s\n", json.getError().buffer());
   exit(0);
}

バイナリ形式のエクスポートファイルからスケルトンデータをロードする場合も、代わりに SkeletonBinary を使用する以外は同じです:

// ロードに使用するSkeletonBinaryを作成(create)し
// ロードしたデータが元データの2倍の大きさになるようにスケールを設定する
SkeletonBinary binary(atlas);
binary.setScale(2);

// スケルトンの.skelファイルをSkeletonDataに読み込む
SkeletonData* skeletonData = binary.readSkeletonDataFile(filename);

// ロードに失敗した場合は、エラーを表示してアプリを終了する
if (!skeletonData) {
   printf("%s\n", binary.getError().buffer());
   exit(0);
}

AnimationStateDataの準備

Spineは、あるアニメーションから別のアニメーションに切り替える際のスムーズなトランジション(クロスフェード)に対応しています。このクロスフェードは、あるアニメーションと別のアニメーションを指定したミックスタイムの間だけミックスすることで実現されています。spine-cppランタイムはこれらのミックスタイムを定義するために AnimationStateData 構造体を提供します:

#include <spine/spine.h>

using namespace spine;

// spAnimationStateDataを作成(create)する
AnimatonStateData* animationStateData = new AnimationStateData(skeletonData);

// 任意のアニメーションのペア間のデフォルトのミックスタイムを秒単位で設定する
animationStateData->setDefaultMix(0.1f);

// "jump"から"walk"までのアニメーション間のミックスタイムを0.2秒に設定し、
// この前後のペアのデフォルトのミックスタイムを上書きする
animationStateData->setMix("jump", "walk", 0.2f);

AnimationStateData で定義されたミックスタイムは、アニメーションの適用時に明示的に上書きすることも可能です(後述)。

スケルトン

セットアップポーズデータ(スケルトンデータ、テクスチャアトラス)は、各ゲームオブジェクト間で共有されることが前提になっています。spine-cppでは、この共有を容易にするために、Skeleton 構造体を提供しています。各ゲームオブジェクトは、自分自身の Skeleton インスタンスを受け取り、そのインスタンスはデータソースとして SkeletonData および Atlas インスタンスを参照します。

Skeleton は、ボーンをプロシージャルに変更したり、アニメーションを適用したり、ゲームオブジェクトに固有のアタッチメントやスキンを設定するなど、自由に変更できますが、基盤となるスケルトンデータやテクスチャアトラスには何ら影響しません。この仕組みと設定により、SkeletonData および Atlas インスタンスを、任意の数のゲームオブジェクト間で共有することができます。

スケルトンの作成

Skeleton インスタンスを作成するには:

Skeleton* skeleton = new Skeleton(skeletonData);

すべてのゲームオブジェクトは、それ自身の Skeleton を必要とします。データの大部分はSkeletonDataAtlas に残り、すべての Skeleton メモリの消費とテクスチャの切り替えを大幅に削減します。したがって、Skeleton のライフタイム(寿命)は、対応するゲームオブジェクトのライフタイムと連動しています。

ボーン

スケルトンはボーンによる階層構造で、ボーンにスロットが、スロットにアタッチメントが取り付けられています。

ボーンを探す

スケルトンに含まれるすべてのボーンは、そのスケルトンから取得できる一意の名前を持ちます:

// その名前のボーンが見つからなかった場合、0 を返します。
Bone* bone = skeleton->findBone("mybone");

ローカルトランスフォーム

ボーンは、rootボーンに戻るまで、その親ボーンに影響されます。例えば、あるボーンを回転させると、そのすべての子ボーンと、さらにそれらのすべての子ボーンも回転されます。これらの階層的なトランスフォームを達成するために、各ボーンは、以下の要素からなる親ボーンに相対するローカルトランスフォームを持っています:

  • 親を基準とした xy 座標。
  • 度単位の rotation(回転)
  • scaleX(スケールX)scaleY(スケールY)
  • 度単位の shearX(シアーX)shearY(シアーY)

ボーンのローカルトランスフォームは、プロシージャルな方法、またはアニメーションの適用によって操作することができます。前者では、ボーンがマウスカーソルを指すようにしたり、足のボーンを地形に沿わせたりといった動的な動作を実装することができます。また、ローカルトランスフォームのプロシージャルな変更と、アニメーションの適用は同時に行うことができます。最終的には、1つのローカルトランスフォームを組み合わせたものになります。

ワールドトランスフォーム

ボーンのローカルトランスフォームをプロシージャルに変更するか、アニメーションを適用するかによって全てのローカルトランスフォームが設定されると、レンダリングと物理のために各ボーンのワールドトランスフォームが必要になります。

この計算はrootボーンから始まり、すべての子ボーンのワールドトランスフォームを再起的に計算します。この計算では、アーティストがSpineエディター内で定義したIKトランスフォームパスなどのコンストレイントも適用されます。

ワールドトランスフォームを計算するには、まずスケルトンのフレーム時間を物理用に更新し、次に実際のトランスフォームを計算する必要があります:

skeleton->update(deltaTime);
skeleton->updateWorldTransform(spine::Physics_Update);

deltaTime は現在のフレームと最後のフレームの間の経過時間を秒単位で指定します。spSkeleton_updateWorldTransform の2番目のパラメータは物理演算を適用するかどうかを指定します。一般的には spine::Physics_Update をデフォルト値にするのが良いでしょう。

結果は各ボーンに保存され、以下のように構成されます:

  • abcd は、ボーンの回転、スケール、シアーを符号化した2x2列のメジャー行列
  • worldXworldY は、ボーンのワールド座標を保管

worldX および worldY は、skeleton->xskeleton->y によってオフセットされることに注意してください。これらの2つのフィールドは、ゲームエンジンのワールド座標系でスケルトンを配置するために使うことができます。

通常、ボーンのワールドトランスフォームは直接変更してはいけません。代わりに、Skeleton::updateWorldTransform() を呼び出すことで、常にスケルトン内のボーンのローカルトランスフォームから派生させる必要があります。ローカルトランスフォームは、プロシージャルに設定することができます。例えば、マウスカーソルを指すようにボーンの回転を設定したり、アニメーションを適用(下記参照)したり、その両方を同時に行うことができます。(プロシージャルな)アニメーションが適用されると、Skeleton::updateWorldTransform() が呼び出され、結果のワールドトランスフォームはローカルトランスフォームと、ボーンに適用されている全てのコンストレイントに基づいて再計算されます。

座標系の変換

他のエンティティや入力イベントからの座標が通常与えられる場所であるため、ワールド座標系でボーンを操作した方が簡単であることがよくあります。しかし、ワールドトランスフォームは直接操作されるべきではないので、ワールド座標系の計算に基づくボーンへの変更を、そのボーンのローカルトランスフォームに適用する必要があります。

spine-cppランタイムは、ボーンの2x2ワールドトランスフォーム行列から回転とスケールの情報を抽出し、位置と回転をローカル空間からワールド空間へ、またはその逆に変換する関数を提供します。これらの関数はすべて、ボーンのワールドトランスフォームがSkeleton::updateWorldTransform() を呼び出すことによって事前に計算されていることを前提としています:

Bone* bone = skeleton->findBone("mybone");

// ワールド空間のX軸に対するボーンの回転を度数で取得する
float rotationX = bone->getWorldRotationX();

// ワールド空間のY軸に対するボーンの回転を度単位で取得する
float rotationY = bone->getWorldRotationY();

// ワールド空間のX軸に対するボーンのスケールを取得する
float scaleX = bone->getWorldScaleX();

// ワールド空間のY軸に対するボーンのスケールを取得する
float scaleY = bone->getWorldScaleY();

// ワールド空間で与えられた位置を、ボーンのローカル空間に変換する
float localX = 0, localY = 0;
bone->worldToLocal(worldX, worldY, localX, localY);

// ボーンのローカル空間で与えられた位置を、ワールド空間に変換する
float worldX = 0, worldY = 0;
bone->localToWorld(localX, localY, worldX, worldY);

// ワールド空間で与えられた回転をボーンのローカル空間に変換する
float localRotationX = bone->worldToLocalRotation(bone)

注意: ボーン(およびそのすべての子)のローカルトランスフォームへの変更は、次にSkeleton::updateWorldTransform()を呼び出した後に、ボーンのワールドトランスフォームに反映されます。

ポジションの決定

デフォルトでは、スケルトンはゲームのワールド座標系の原点にあると仮定されます。ゲームのワールド座標系でスケルトンを配置するには、xおよびyプロパティを使用します:

// ワールド空間でゲームオブジェクトに追従するスケルトンを作成
skeleton->setX(myGameObject->worldX);
skeleton->setY(myGameObject->worldY);

注意: スケルトンの x および y プロパティへの変更は、次にSkeleton::updateWorldTransform() を呼び出した後に、ボーンのワールドトランスフォームに反映されます。

反転

スケルトンは垂直または水平に反転させることができます。これにより、ある方向用に作成したアニメーションを反対方向に再利用したり、Y軸が下向きの座標系で作業することができます(SpineはデフォルトでY軸が上向きの座標系を想定しています):

// x軸を中心に垂直に反転する
skeleton->setScaleX(-1);

// y軸を中心に水平反転しない
skeleton->setScaleY(-1);

注意: スケルトンの scaleXscaleY プロパティに対する変更は、次にSkeleton::updateWorldTransform() を呼び出した後に、ボーンのワールドトランスフォームに反映されます。

スキンの設定

Spineのスケルトンを作成したアーティストは、同じスケルトンに複数のスキンを追加して、女性版と男性版など、視覚的なバリエーションを追加することができます。spine-cppランタイムはスキンをSkinインスタンスに格納します。

ランタイムにおけるスキンは、どのアタッチメントをスケルトンのどのスロットに入れるかを定義したマップです。すべてのスケルトンは少なくとも1つのスキンを持っており、どのアタッチメントがスケルトンのセットアップ・ポーズのどのスロットにあるのかを定義しています。追加されたスキンは、それらを識別するための名前を持ちます。

spine-cppでスケルトンにスキンを設定:

// 名前によってスキンを設定
skeleton->setSkin("my_skin_name");

// NULLを渡してデフォルトのセットアップポーズのスキンを設定
skeleton->setSkin(NULL);

注意: スキンの設定は、以前に設定されたスキンやアタッチメントが考慮されます。スキン設定の詳細については、総合的なランタイムガイドを参照してください。

アタッチメントの設定

spine-cppは、例えば武器を切り替えたりするために、スケルトンのスロットに直接アタッチメントを1つ設定することができます。アタッチメントは、まずアクティブなスキンで検索され、これが失敗した場合は、デフォルトのセットアップポーズスキンで検索されます:

// "hand"スロットに"sword"というアタッチメントをセットする
skeleton->setAttachment("hand", "sword");

// スロット"hand"のアタッチメントをクリアして、何も表示されないようにする
skeleton->setAttachment(skeleton, "hand", "");

ティント

スケルトンのカラーを設定することで、スケルトン内のすべてのアタッチメントをティント(着色)することができます:

// すべてのアタッチメントを赤で着色し、スケルトンを半分だけ透明にする
skeleton->getColor().set(1, 0, 0, 0.5f);

注意: spine-cppの色はRGBAで示され、各チャンネルの値は[0-1]の範囲になります。

スケルトンを描画するとき、レンダラーはスケルトン上のスロットの表示順序をたどり、各スロットに現在アクティブなアタッチメントを描画します。スケルトンのカラーに加えて、各スロットも実行時に操作可能なカラーを持っています:

Slot* slot = skeleton->findSlotByName("mySlot");
slot->getColor().set(1, 0, 1, 1);

なお、スロットカラーもアニメーションさせることができます。スロットカラーを手動で変更した後に、そのスロットカラーをキーにしたアニメーションを適用すると、手動での変更内容は上書きされることに注意してください。

アニメーションの適用

Spineエディターでは、アーティストが一意の名前のアニメーションを複数作成することができます。アニメーションとは、タイムラインの集合体です。各タイムラインは、ボーンやスケルトンのどのプロパティが、どのフレームでどのような値に変化するかを指定します。タイムラインには、時間経過によるボーンのトランスフォームを定義するタイムラインから、表示順序を変更するタイムラインまで、様々なタイプがあります。タイムラインはスケルトンデータの一部で、spine-cppの SkeletonData 内の Animation インスタンスに格納されています。

Timeline API

spine-cppは、タイムラインを直接操作する必要が生じた場合に、Timeline APIを提供します。この低レベルの機能により、アーティストが定義したアニメーションがスケルトンに適用される方法を完全にカスタマイズすることができます。

AnimationState API

ほとんどの場合、Timeline APIではなくAnimationState APIを使用した方が良いでしょう。AnimationState APIは、アニメーションの時間経過による適用、アニメーションのキューイング、アニメーション間のミックス、複数のアニメーションの同時適用などのタスクを、低レベルのTimeline APIよりもかなり簡単に実行できます。AnimationState API は内部的には Timeline API を使用しており、ラッパーと見なすことができます。

spine-cppは AnimationState クラスでアニメーションの状態を表現します。スケルトンと同じように、AnimationState はゲームオブジェクトごとにインスタンス化されます。一般的には、ゲーム内のゲームオブジェクトごとに、1つの Skeleton と1つの AnimationState インスタンスを持つことになります。また Skeleton と同様に、AnimationState も(アニメーションとそのタイムラインが格納されている) SkeletonData と(ミックスタイムが格納されている)AnimationStateData を他のすべての AnimationState インスタンスと共有し、同じスケルトンデータをソースとすることができます。

AnimationStateの作成

AnimationState インスタンスを作成するには:

AnimationState* animationState = new AnimationState(animationStateData);

この関数は、通常スケルトンデータのロードの際に作成される AnimationStateData を受け取り、デフォルトのミックスタイムやクロスフェード用の特定のアニメーション間のミックスタイムを定義します。

トラックとキューイング

AnimationStateは、1つまたは複数のトラックを管理します。各トラックはアニメーションのリストで、トラックに追加された順番に再生します。これはキューイングと呼ばれます。トラックは0から始まるインデックスを持っています。

以下のようにして、トラック上にアニメーションをキューすることができます:

// アニメーション"walk"をトラック0に、遅延(delay)なしで追加し、無限にループさせる
int track = 0;
bool loop = true;
float delay = 0;
animationState->addAnimation(track, "walk", loop, delay);

また、複数のアニメーションを一度にキューして、アニメーションシーケンスを作成することができます:

// walkをスタート (ループ有り)
animationState->addAnimation(0, "walk", true, 0);

// 3秒後にジャンプ
animationState->addAnimation(0, "jump", false, 3);

// ジャンプが完了したら、無限に待機(idle)を再生
animationState->addAnimation(0, "idle", true, 0);

また、トラック内にキューされているすべてのアニメーションをクリアすることができます:

// トラック0にキューされているアニメーションを全てクリアする
animationState->clearTrack(0);

// 全トラックでキューされているアニメーションを全てクリアする
animationState->clearTracks(animationState);

クリアして新しいアニメーションをトラックに追加する代わりに、AnimationState::setAnimation() を呼び出すこともできます。これはすべてのトラックをクリアしますが、クリアする前に最後に再生されたアニメーションが何であったかを記憶し、新たに設定されたアニメーションにクロスフェードします。これにより、あるアニメーション・シーケンスから次のアニメーション・シーケンスへスムーズに移行することができます。AnimationState::setAnimation() を呼び出した後に AnimationState::addAnimation() を呼び出すと、さらにアニメーションをトラックに追加することができます:

// 現在トラック0で再生されているものが何であれ、トラックをクリアして
// "shot"アニメーションにクロスフェードする。これはループしません(最後のパラメータで指定)
animationState->addAnimation(0, "shot", false, 0);

// 射撃の後、再び待機(idle)させる
animationState->addAnimation(0, "idle", true, 0);

アニメーションからスケルトンのセットアップポーズにクロスフェードするには、AnimationState::setEmptyAnimation()AnimationState::addEmptyAnimation() を使います。前者は現在のトラックをクリアしてスケルトンにクロスフェードし、後者はトラック上のアニメーションシーケンスの一部としてセットアップポーズにクロスフェードをエンキューします。

// 現在トラック0で再生されているものが何であれ、トラックをクリアして
// セットアップポーズに0.5秒(ミックスタイム)でクロスフェードする
animationState->setEmptyAnimation(0, 0.5f);

// トラック0のアニメーションシーケンスの一部として、
// セットアップポーズに0.5秒間、クロスフェードを追加し、1秒間の遅延(delay)を設定する
animationState->addEmptyAnimation(0, 0.5f, 1)

シンプルなゲームでは、通常、1つのトラックだけ使用すれば十分に目的を達成できます。より複雑なゲームでは、例えば、射撃中に歩行アニメーションを同時に再生するなど、別々のトラックでアニメーションをキューイングすることもできます。ここでSpineの真価が発揮されます:

// トラック0に"walk"アニメーションを無限に適用する
animationState->setAnimation(0, "walk", true);

// トラック1に"shot"アニメーションを1回だけ適用する
animationState->setAnimation(1, "shot", false);

このように同時にアニメーションを適用すると、両方のアニメーションがキーにしているすべての値について、上位トラックのアニメーションが下位トラックのアニメーションを上書きしてしまうことに注意してください。つまり、アニメーションのオーサリング(組み合わせ)を行う場合、同時に再生する2つのアニメーションが、そのスケルトンで同じ値(同じボーン、アタッチメント、色など)をキーにしないことを確認してください。加算式アニメーションブレンディングであれば、同じスケルトンプロパティに影響を与える異なるトラック上のタイムラインの結果を足し算することができます。

TrackEntryにより、異なるトラックでのアニメーションのミキシングを制御することができます。

トラックエントリ

AnimationStateのトラックでアニメーションをエンキューするたびに、対応する関数はTrackEntry インスタンスを返します。このトラックエントリにより、キューされたアニメーションや、同じトラックや他のトラック上のアニメーションとのミキシング挙動をさらにカスタマイズすることができます。完全なAPIはTrackEntryのドキュメントを参照してください。

例えば、AnimationStateData で定義された"walk"と"run"アニメーションのミックスタイムが、このゲームオブジェクトの現在の状況に対して高すぎると仮定してみましょう。このキューに入れられたアニメーションのために、"walk"と"run"のミックスタイムをアドホックに変更することができます:

// 無限に"walk"する
sanimationState->setAnimation(0, "walk", true);

// ある時点で、"run"アニメーションをキューに入れる。この特定の呼び出しを速くするために、
// `AnimationStateData`で定義された"walk"と"run"の間のミックスタイム(仮に0.5秒とします)
// を速くします(ここでは0.1秒に設定)。
TrackEntry* entry = animationState->addAnimation(0, "run", true, 0);
entry->setMixTime(0.1f);

TrackEntry を保持することで、時間の経過とともに変更することができます。TrackEntry は、そのトラックにアニメーションがキューイングされている間、有効です。アニメーションが完了すると、 TrackEntry は割り当て解除されます。それ以降のアクセスは無効となり、セグメンテーション違反が発生するでしょう。リスナーを登録すれば、アニメーションやトラックエントリが無効になったときに通知を受けることができます。

イベント

AnimationStateは、キューイングされたアニメーションを再生しながらイベントを生成し、リスナーに以下の変更を通知します:

  • アニメーションが開始された(start)
  • トラックをクリアするなどにより、アニメーションが中断された(interrupt)
  • アニメーションが完了した(complete)。※ループしている場合は複数回発生
  • アニメーションが終了した(end)。※中断されたか、または完了しループされていない場合に発生。
  • アニメーションとそれに対応する TrackEntry破棄された(dispose)、且つ無効になった。
  • ユーザーが定義したイベント(event)が発生した。

これらのイベントは、AnimationState、またはAnimationStateから返される個々の TrackEntry インスタンスに関数を登録することで、リッスンすることができます。

// イベントが発生したときに呼び出される関数を定義する
void callback (AnimationState* state, EventType type, TrackEntry* entry, Event* event) {
   const String& animationName = (entry && entry->getAnimation()) ? entry->getAnimation()->getName() : String("");

   switch (type) {
   case EventType_Start:
      printf("%d start: %s\n", entry->getTrackIndex(), animationName.buffer());
      break;
   case EventType_Interrupt:
      printf("%d interrupt: %s\n", entry->getTrackIndex(), animationName.buffer());
      break;
   case EventType_End:
      printf("%d end: %s\n", entry->getTrackIndex(), animationName.buffer());
      break;
   case EventType_Complete:
      printf("%d complete: %s\n", entry->getTrackIndex(), animationName.buffer());
      break;
   case EventType_Dispose:
      printf("%d dispose: %s\n", entry->getTrackIndex(), animationName.buffer());
      break;
   case EventType_Event:
      printf("%d event: %s, %s: %d, %f, %s\n", entry->getTrackIndex(), animationName.buffer(), event->getData().getName().buffer(), event->getIntValue(), event->getFloatValue(),
            event->getStringValue().buffer());
      break;
   }
   fflush(stdout);
}

// AnimationStateに関するリスナーとして関数を登録する。この関数は、
// AnimationStateにキューされたすべてのアニメーションに対して呼び出されます
animationState->setListener(myListener);

// あるいは、エンキューされた特定のアニメーションのイベントのリスナーとしてこの関数を登録することもできます
TrackEntry* trackEntry = animationState->setAnimation(0, "walk", true);
trackEntry->setListener(myListener);

ユーザー定義イベントは、アニメーションの中で足音などを再生する時間をマークアップするのに最適な機能です。

新しいアニメーションの設定など、リスナー内で行われたAnimationStateの変更は、次に AnimationState::apply が呼び出されるまでスケルトンに適用されません。リスナー内で変更を即座に適用することができます:

void myListener(AnimationState* state, EventType type, TrackEntry* entry, Event* event) {
   if (somecondition) {
      state->setAnimation(0, "run", false);
      state->update(0);
      state->apply(skeleton);
   }
}

AnimationStateを適用する

AnimationStateは、本質的に時間ベースです。状態を変化させるには、tickごとに更新する必要があり、最後の更新からの経過時間を秒単位で指定します:

state->update(deltaTimeInSeconds);

これにより、各トラックのアニメーションの再生を進めたり、クロスフェードを調整したり、登録したリスナーを呼び出したりすることができます。

AnimationStateを更新した後、それをスケルトンに適用して、そのボーンのローカルトランスフォーム、アタッチメント、スロットカラー、表示順序、その他アニメーションできるものを更新するには以下を行います:

state->apply(*skeleton);

スケルトンのポーズとアニメーションが完了したら、最後にボーンのワールドトランスフォームを更新して、レンダリングや物理演算の準備をします:

skeleton->update(deltaTime);
skeleton->updateWorldTransform();

全てをまとめた結果

ここでは、ロード、インスタンス化からアニメーションの適用まで、上記のすべてをまとめた簡単な例を示します(スクロールするとすべてのコードが表示されます):

// 全スケルトンで共有されるセットアップポーズデータ
Atlas* atlas;
SkeletonData* skeletonData;
AnimationStateData* animationStateData;

// 5体のスケルトンインスタンスとそれらのAnimationState
Skeleton* skeleton[5];
AnimationState* animationState[5];
char* animationNames[] = { "walk", "run", "shot" };

void setup() {
   // アトラスのテクスチャを読み込めるようにエンジンをセットアップする、ウィンドウを作成する、など。
   engine_setup();

   // テクスチャアトラスのロード
   atlas = new Atlas("spineboy.atlas", MyEngineTextureLoader());
   if (atlas.getPages().size() == 0) {
      printf("Failed to load atlas");
      delete atlas;
      exit(0);
   }

   // スケルトンデータのロード
   SkeletonJson json(atlas);
   skeletonData = json.readSkeletonDataFile("spineboy.json");
   if (!skeletonData) {
      printf("Failed to load skeleton data");
      delete atlas;
      exit(0);
   }

   // 各ミックスタイムの設定
   animationStateData = new AnimationStateData(skeletonData);
   animationStateData->setDefaultMix(0.5f);
   animationStateDAta->setMix("walk", "run", 0.2f);
   animationStateData->setMix("walk", "shot", 0.1f);
}

void mainLoop() {
   // 5つのゲームオブジェクトを表す5体のスケルトンインスタンスと
   // AnimationStateを作成。
   for (int i = 0; i < 5; i++) {
      // スケルトンを作成し、ランダムな位置に配置する
      Skeleton* skeleton = new Skeleton(skeletonData);
      skeleton->setX(random(0, 200));
      skeleton->setY(random(0, 200));

      // AnimationStateを作成し、ランダムなアニメーションをループさせながらエンキューする
      AnimationState *animationState = new AnimationState(animationStateData);
      animationState->setAnimation(0, animationNames[random(0, 3)], true);
   }

   while (engine_gameIsRunning()) {
      engine_clearScreen();

      // ゲームオブジェクトのアップデート
      for (int i = 0; i < 5; i++) {
         Skeleton* skeleton = skeletons[i];
         AnimationState* animationState = animationStates[i];

         // まず、AinmationStateをデルタタイムで更新する
         animationState->update(engine_getDeltaTime());

         // 次に、スケルトンにそのAinmationStateを適用する
         animationState->apply(skeleton);

         // 物理演算のためにスケルトンのフレーム時間を更新する
         skeleton->update(engine_getDeltaTime());

         // レンダリングのためのワールドトランスフォームを計算する
         skeleton->updateWorldTransform();

         // スケルトンのレンダリングをエンジンに引き継ぐ
         engine_drawSkeleton(skeleton);
      }
   }

   // インスタンスデータを破棄する。通常は、ゲームオブジェクトを
   // 破棄するときに行います。
   for (int i = 0; i < 5) {
      delete skeletons[i];
      delete animationStates[i];
   }
}

void dispose() {
   // 共有リソースをすべて破棄する
   delete atlas;
   delete skeletonData;
   delete animationStateData;
}

int main(int argc, char* argv) {
   setup();
   mainLoop();
   dispose();
}

セットアップポーズデータ(Atlas, SkeletonData, AnimationStateData) とインスタンスデータ (Skeleton, AnimationState)の区別とそのライフタイムの違いに注意してください。

メモリ管理

私たちは spine-cpp のメモリ管理をできるだけ単純化するように努めました。new でアロケートされたクラスや構造体は、対応する delete で解放される必要があります。クラスインスタンスの寿命は、それがどのようなタイプのクラスインスタンスであるかに依存します。一般的なルールは以下の通りです:

  • ゲームまたはレベル起動時にインスタンスデータ(Atlas, SkeletonData, AnimationStateData)と共有するセットアップポーズデータを作成し、ゲームまたはレベルの終了時に破棄します。
  • 対応するゲームオブジェクトの生成時にインスタンスデータ(Skeleton, AnimationState)を作成し、対応するゲームオブジェクトの破棄時に破棄します。

トラックエントリ(TrackEntry)は、AnimationStateの関数 (AnimationState::setAnimation(), AnimationState::addAnimation(), AnimationState::setEmptyAnimation(), AnimationState::addEmptyAnimation())が呼び出されてからEventType_disposeイベントがリスナーに送られるまで有効です。このイベントの後にトラックエントリにアクセスすると、セグメンテーション違反が発生する可能性があります。

構造体を作成する際、他の構造体を参照として渡すことがよくあります。参照元の構造体が、参照先の構造体を破棄することはありません。例えば、SkeletonSkeletonDataを参照し、そのspSkeletonDataAtlasを参照しています。

  • Skeleton を破棄しても、SkeletonDataAtlas は破棄されません。これは、SkeletonData は他の Skeleton インスタンスと共有されている可能性が高いためです。
  • SkeletonData を破棄しても、Atlas は破棄されません。これは、アトラスに複数のスケルトンの画像が含まれている場合など、Atlas が他の SkeletonData インスタンスと共有されることがあるためです。

カスタムアロケーターを使用する場合、独自の SpineExtension を実装することで、Spineのアロケーションストラテジー(割り当て戦略)を上書きできます。カスタムSpineExtensionDefaultSpineExtension から派生して、_alloc_calloc_realloc_free(_readFileの実装を継承)をオーバーライドする必要があります。その後、プログラム起動時に spine::SpineExtension::setInstance() を呼び出すことで、拡張子を設定することができます。また、Spineランタイムエンジンとの連携を行わない場合は、spine::getDefaultExtension() メソッドを実装して、エンジンのメモリ管理やファイル管理と互換性のある拡張子をSpineに提供する必要があります。

また、Spineには簡単なメモリーリーク検出機能があり、spine/Debug.hDebugExtension という SpineExtension のラッパークラスが用意されています。DebugExtension で他のエクステンションをラップすると、Spineオブジェクトを以下のように割り当てた場合、ファイルの場所と行番号とともに、割り当てを追跡できるようになります:

Skeleton* skeleton = new (__FILE__, __LINE__) Skeleton(skeletonData);

__FILE____LINE__ 引数は、デバッグ拡張モジュールがどこでアロケーションが発生したかを記録するために使用されます。プログラムが終了するとき、デバッグ拡張がstdout(標準出力)にレポートを出力するようにすることができます。

#include <spine/Extension.h>
#include <spine/Debug.h>

static DebugExtension *debugExtension = NULL;

// これは、Spineが最初の拡張インスタンスを取得するために使用されます。
SpineExtension* spine::getDefaultExtension() {
   // メモリ管理に標準的なmallocを使用するDefaultSpineExtensionを返し、
   // それをdebugExtensionでラップします。
   debugExtension = new DebugExtension(new DefaultSpineExtension());
   return debugExtension;
}

int main (int argc, char** argv) {
   ... your app code allocating Spine objects via `new (__FILE__, __LINE__) SpineClassName()` and deallocating via `delete instance` ...

   debugExtension->reportLeaks
();
}

spine-cppをエンジンに統合する

ソースの統合

spine-cppは、ランタイムGitリポジトリのspine-cpp/spine-cppフォルダにあるC++ヘッダーと実装のファイル群です。ソースをプロジェクトにコピーするか、CMakeの FetchContent を使ってください。

ソースをコピーする場合

  1. まずSpineランタイムのリポジトリをクローンします。使用しているSpineエディターのバージョンに対応するブランチを使用してください。
  2. spine-cpp/spine-cpp/src/spineフォルダ内のファイルをご自身のビルドに含めます。
  3. spine-cpp/spine-cpp/includeフォルダをご自身のヘッダー検索パスに追加します。

CMakeのFetchContentを使用する場合

Spineバージョン4.2以降では、CMakeの FetchContent 機能を使うことで、以下の CMakeLists.txt ファイルの例のようにspine-cppランタイムを簡単にプロジェクトに統合することができます:

cmake_minimum_required(VERSION 3.14)
project(MyProject C)

set(CMAKE_C_STANDARD 99)
set(CMAKE_C_STANDARD_REQUIRED ON)
set(FETCHCONTENT_QUIET NO)

# spine-runtimesリポジトリを取得し、spine-cppライブラリを利用可能にする
include(FetchContent)
FetchContent_Declare(
spine-runtimes
GIT_REPOSITORY https://github.com/esotericsoftware/spine-runtimes.git
GIT_TAG 4.2
GIT_SHALLOW TRUE
)
FetchContent_MakeAvailable(spine-runtimes)
FetchContent_GetProperties(spine-runtimes)
if(NOT spine-runtimes_POPULATED)
   FetchContent_Populate(spine-runtimes)
endif()
add_subdirectory(${spine-runtimes_SOURCE_DIR}/spine-c ${CMAKE_BINARY_DIR}/spine-runtimes)

# シンプルなC++実行ファイルを作成
file(GLOB SOURCES "src/*.cpp")
add_executable(MyExecutable ${SOURCES})
target_include_directories(MyExecutable PRIVATE src/)

# spine-cpp ライブラリをリンクする
target_link_libraries(MyExecutable spine-cpp)

メモリとファイル入出力の実装

プロジェクトをコンパイルすると、spine-cppランタイムが実装を期待する関数に対してリンカエラーが発生することがあります。例えば、Clangでコンパイルした場合に以下のようなエラーになる場合があります:

Undefined symbols for architecture x86_64:
"spine::getDefaultExtension()", referenced from:
    spine::SpineExtension::getInstance() in libspine-cpp.a(Extension.cpp.o)
ld: symbol(s) not found for architecture x86_64
clang: error: linker command failed with exit code 1 (use -v to see invocation)

リンカはSpineExtensionを返す関数を見つけられませんでした。spine-cppランタイムは、お使いのエンジンが提供するAPIを使用して SpineExtension から派生したクラスを実装することを期待します。この拡張はExtension.hで定義されています。

mallocfreeFILE ベースのファイル入出力の使用に満足している場合は、DefaultSpineExtension を使用して、このように不足している関数を実装することができます:

#include <spine/Extension.h>

spine::SpineExtension *spine::getDefaultExtension() {
   return new spine::DefaultSpineExtension();
}

それ以外の場合は、SpineExtension または DefaultSpineExtension から派生して、_malloc_calloc_realloc, _free_readFile メソッドをオーバーライドします。

TextureLoaderの実装

spine-cppの Atlas クラスは、一つのアトラスページに対してエンジン固有のテクスチャ表現をロードして作成するために、TextureLoader インスタンスを渡されることを想定しています。TextureLoaderクラス は、パスを与えられたアトラスページのメソッドをロードする load、そしてテクスチャを破棄する unload の2つのメソッドを持っています。

load 関数は、テクスチャを AtlasPage::texture に代入してアトラスページに保存することができます。これにより、後でアトラスページの領域を介してアタッチメントが参照するテクスチャを取得するのが簡単になります。

load 関数の path パラメータはページ画像ファイルへのパスで、 Atlas コンストラクタに渡された .atlas ファイルパスからの相対パスか、メモリからアトラスをロードする2番目の Atlas コンストラクタの dir パラメータからの相対パスです。

load 関数 のpath パラメータはページ画像ファイルのパスで、Atlas コンストラクタに渡された .atlas ファイルのパスからの相対パス、またはメモリからアトラスをロードする2番目の Atlas コンストラクタの dir パラメータからの相対パスとなります。

説明のために、お使いのエンジンがテクスチャを扱うために以下のようなAPIを提供しているとします:

struct Texture {
   // ... OpenGL handle, image data, whatever ...
   int width;
   int height;
};

Texture* engine_loadTexture(const char* file);
void engine_disposeTexture(Texture* texture);

TextureLoaderの実装は、次のように簡単です:

#include <spine/TextureLoader.h>

class MyTextureLoader: public TextureLoader {
   public:
      TextureLoader() { }

      virtual ~TextureLoader() { }

      // アトラスがページのテクスチャをロードするときに呼び出されます。
      virtual void load(AtlasPage& page, const String& path) {
         Texture* texture = engine_loadTexture(path);

         // テクスチャのロードに失敗した場合は、単にリターンします。
         if (!texture) return;

         // TextureをrendererObjectに格納し、後でレンダリングのために
         // それを取得できるようにします。
         page.setRendererObject(texture);

         // テクスチャの幅と高さをAtlasPageに保存し、
         // spine-cppがレンダリングのためにテクスチャの座標を
         // 計算できるようにします。
         page.setWidth(texture->width)
         page.setHeight(texture->height);
      }

      // アトラスが破棄され、それ自身がアトラスページを破棄するときに呼ばれます。
      virtual void unload(void* texture) {
         // textureパラメータは page->setRendererObject() でページに保存したテクスチャです。
         engine_disposeTexture(texture);
      }
}

出来上がったTextureLoaderは、2つのAtlasコンストラクタのいずれかに渡すことができます。

レンダリングの実装

Spineスケルトンのレンダリングでは、現在アクティブなすべての領域およびメッシュアタッチメントを、現在の表示順序でレンダリングします。表示順序は、スケルトンのスロットの配列として定義されます。さらに、クリッピングアタッチメントが、領域およびメッシュアタッチメントをクリップすることがあります。

spine-cppが提供している SkeletonRenderer クラスは、スケルトンのアクティブなアタッチメントから、テクスチャ、頂点カラー、ブレンドが適用された三角メッシュを簡単に生成することができます。

説明のために、お使いのエンジンに以下のようなAPIがあると想定します:

// UVを伴う単一の頂点
struct Vertex {
   // x/y 平面上の位置
   float x, y;

   // UV座標
   float u, v;

   // パックされたRGBAカラー
uint32_t color;
};

enum BlendMode {
   // これらがOpenGLのソース/デスティネーションブレンドモードにどのように変換されるかについては、
   // こちらをご覧ください。 http://esotericsoftware.com/git/spine-runtimes/blob/spine-libgdx/spine-libgdx/src/com/esotericsoftware/spine/BlendMode.java#L37
   BLEND_NORMAL,
   BLEND_ADDITIVE,
   BLEND_MULTIPLY,
   BLEND_SCREEN,
}

// 与えられたメッシュを描画する。
// - vertices はVertex構造体の配列へのポインタです。
// - indices は、インデックスの配列へのポインタです。3の連続するインデックスは三角形を形成します。
// - numIndicesは、インデックスの数です。三角形の頂点が3つであることから、3で割り切れる必要があります。
// - texture は、使用するテクスチャです。
// - blendMode は、使用するブレンドモードです。
void engine_drawMesh(Vertex* vertices, unsigned short* indices, size_t numIndices, Texture* texture, BlendMode blendmode);

そして、レンダリング処理は次のように実装することができます:

// 頂点を一時的に保管するコンテナ
spine::Vector<Vertex> vertices;
// 単一のSkeletonRendererインスタンス (レンダリングがシングルスレッドで実行されると仮定)
SkeletonRenderer skeletonRenderer;

void drawSkeleton(Skeleton &skeleton) {
RenderCommand *command = skeletonRenderer.render(skeleton);
while (command) {
    Vertex vertex;
    float *positions = command->positions;
    float *uvs = command->uvs;
    uint32_t *colors = command->colors;
    uint16_t *indices = command->indices;
    Texture *texture = (Texture *)command->texture;   
    for (int i = 0, j = 0, n = command->numVertices * 2; i < n; ++i, j += 2) {
       vertex.x = positions[j];
       vertex.y = positions[j + 1];
       vertex.u = uvs[j];
       vertex.v = uvs[j + 1];
       vertex.color = colors[i];
       vertices->add(vertex);
    }
    BlendMode blendMode = command->blendMode; // Spineのブレンドモードとエンジンのブレンドモードが同じ
    engine_drawMesh(vertices->buffer(), command->indices, command->numIndices, texture, blendMode)
    vertices.clear()
    command = command->next;
}
}

SkeletonRenderer::render() は、RenderCommand インスタンスのリンクリストを返します。各 RenderCommand は、頂点、インデックス、ブレンドモード、テクスチャで構成される1つのメッシュを表します。お使いのエンジンで RenderCommand をレンダリングするには、頂点を必要なフォーマットに変換し、ブレンドモードとテクスチャを設定し、トランスフォームした頂点とインデックスを描画します。これにより、バッチ処理を自分で実装することなく、エンジンの描画呼び出し回数を最小限に抑えることができます。RenderCommand::next を使って繰り返しながら、残りのレンダーコマンドを描画します。

SkeletonRenderer は、ブレンドモードとテクスチャが同じであれば、複数のアタッチメントのメッシュを1つの RenderCommand にバッチします。

上記の実装は、2色ティントを除くすべてのSpine機能をサポートしています。OpenGLを使用した実装例についてはspine-glfwランタイムを参照してください。