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ファイル: これはテクスチャアトラスの各ページで、スケルトンが使用するイメージを含んでいます。

注意: スケルトンごとに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トランスフォームパスなどのコンストレイントも適用されます。

このようにワールドトランスフォームを計算するために、spine-cppランタイムは以下のメソッドを提供しています:

skeleton->updateWorldTransform();

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

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

worldXworldYは、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->setFlipX(true);

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

注意: スケルトンのflipXflipYプロパティに対する変更は、次に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->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->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++ヘッダーと実装のファイル群です。

  1. Spineランタイムのリポジトリをクローンするか、ZIP ファイルとしてダウンロードします。
  2. spine-cpp/spine-cpp/src/spineフォルダからご自身のプロジェクトのソースフォルダにソースをコピーし、それらがプロジェクトのコンピレーションステップの一部になっていることを確認します。
  3. ヘッダーの入ったspineフォルダをspine-cpp/spine-cpp/includeフォルダからご自身のプロジェクトのヘッダーフォルダにコピーし、コンパイラーがヘッダーを探すのに使うインクルードパスの一部であることを確認します。spine-cppのソースは#include "spine/xxx.h"を介してヘッダーをインクルードするので、spineフォルダは必ず残しておいてください。

プロジェクトをコンパイルすると、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を介して、このクラスから派生したクラスを実装することを期待します。拡張子はExtension.hで定義されています。

注意: spine-cppランタイムをベースとしたSpine公式ランタイムを使用している場合、これらの関数とレンダリングはすでに実装されています。このセクションは無視していただいて結構です。

もし、Spineが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クラス は2つのメソッドを持っています。パスを与えられたアトラスページのメソッドをロードするload、そしてテクスチャを破棄するunloadです。

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

loadメソッドは、エンジンが読み込んだテクスチャファイルに従って、AtlasPageの幅と高さをピクセル単位で設定することになっています。このデータは、spine-cppによるテクスチャ座標の計算に必要です。

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スケルトンのレンダリングは、現在アクティブなアタッチメントをすべて現在の表示順序でレンダリングすることを意味します。表示順序は、スケルトン上のスロットの配列として定義されます。

描画可能なアタッチメント(領域(変形可能な) メッシュ)は、UVマッピングされ、頂点カラーのついた三角形のメッシュを定義します。Both RegionAttachmentMeshAttachmentの両方はHasRendererObjectインターフェースを実装しており、エンジン固有のTextureLoaderを介して以前にロードされたアトラスページのテクスチャを取得し、アタッチメントの三角形がマップされるようにします。

スケルトンをプロシージャルに、またはAnimationStateでアニメーションさせ、Skeleton::updateWorldTransform()を呼び出してスケルトンボーンのワールド変換を更新したと仮定すると、次のようにスケルトンをレンダリングすることができます:

  • スケルトンの表示順序配列の各スロットにおいて
    • スロットから現在アクティブなアタッチメントを取得します (アタッチメントがアクティブでない場合は null を指定できます)
    • スロットからブレンドモードを取得し、ご使用のエンジンのAPIに変換します
    • スケルトンとスロットのカラーを元にティントカラーを計算します
    • アタッチメントのタイプを確認します
      • それが領域アタッチメントであった場合
        • アタッチメントのレンダーオブジェクトからアトラスページのテクスチャを取得します
        • RegionAttachment::computeWorldVerticesを呼び出して、ワールド頂点を計算します。
        • アタッチメントのティントカラーを、各チャンネルごとに [0-1] の範囲でRGBAとして与えられるスケルトンカラーとスロットカラーを乗算することによって計算します
        • ワールド空間の頂点、UV、カラーを三角形のメッシュに合成します
        • お使いのエンジンのAPIでテクスチャをバインドします
        • レンダリング用のメッシュをSubmitします
      • それがメッシュアタッチメントであった場合
        • アタッチメントのレンダーオブジェクトからアトラスページのテクスチャを取得する
        • VertexAttachment::computeWorldVerticesを呼び出して、ワールド頂点を計算します。
        • アタッチメントのティントカラーを、各チャンネルごとに [0-1] の範囲で RGBA として与えられるスケルトンカラーとスロットカラーを乗算することによって計算します
        • ワールド空間の頂点、UV、カラーを三角形のメッシュに合成します
        • お使いのエンジンのAPIでテクスチャをバインドします
        • レンダリング用のメッシュをSubmitします

UVマップされた頂点カラーのついた三角メッシュのレンダリングが可能なエンジンであれば、これをお使いのエンジンに翻訳することは些細なことであるはずです。説明のために、次のようなエンジンAPIを想定してみましょう:

// UVを伴う単一の頂点
struct Vertex {
   // Position in x/y plane
   float x, y;

   // UV座標
   float u, v;

   // カラー、各チャンネルは0-1の範囲
   // (本当は32ビットRGBAパックカラーであるべきです)
   spine::Color 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;
unsigned short quadIndices[] = {0, 1, 2, 2, 3, 0};

void drawSkeleton(Skeleton* skeleton) {
   // スケルトンの表示順配列の各スロットごとに処理する
   for (size_t i = 0, n = skeleton->getSlots().size(); i < n; ++i) {
      Slot* slot = skeleton->getDrawOrder()[i];

      // 現在アクティブなアタッチメントを取得し、
      // そのスロットでアクティブなアタッチメントがない場合は
      // 次のスロットに進みます
      Attachment* attachment = slot->getAttachment();
      if (!attachment) continue;

      // スロットからブレンドモードを取得し、
      // エンジンのブレンドモードに変換する
      BlendMode engineBlendMode;
      switch (slot->getData().getBlendMode()) {
         case BlendMode_Normal:
            engineBlendMode = BLEND_NORMAL;
            break;
         case BlendMode_Additive:
            engineBlendMode = BLEND_ADDITIVE;
            break;
         case BlendMode_Multiply:
            engineBlendMode = BLEND_MULTIPLY;
            break;
         case BlendMode_Screen:
            engineBlendMode = BLEND_SCREEN;
            break;
         default:
            // unknownのSpineブレンドモードだった場合、
            // 通常のブレンドモードにフォールバックする
            engineBlendMode = BLEND_NORMAL;
      }

      // スケルトンカラーとスロットカラーを元に、ティントカラーを計算する。
      // 各カラーチャンネルは、[0-1]の範囲で与えられます。
      // もし、ご使用のエンジンがカラーチャンネルに整数範囲を使用している場合は、
      // 255倍してintにキャストしなければいけない可能性があります。
      Color skeletonColor = skeleton->getColor();
      Color slotColor = slot->getColor();
      Color tint(skeletonColor.r * slotColor.r, skeletonColor.g * slotColor.g, skeletonColor.b * slotColor.b, skeletonColor.a * slotColor.a);

      // アタッチメントのタイプに応じて、頂点配列、インデックス、テクスチャを埋める
      Texture* texture = NULL;
      unsigned short* indices = NULL;
      if (attachment->getRTTI().isExactly(RegionAttachment::rtti)) {
         // spRegionAttachmentにキャストして、rendererObjectを取得し、
         // ワールド頂点を計算できるようにする。
         RegionAttachment* regionAttachment = (RegionAttachment*)attachment;

         // エンジン固有のTextureは、ロード時にアタッチメントに割り当てられた
         // AtlasRegionに格納されています。これは、領域アタッチメントがマッピングされた画像を含む
         // テクスチャアトラスページを表します。
         texture = (Texture*)((AtlasRegion*)regionAttachment->getRendererObject())->page->getRendererObject();

         // 頂点のための十分なスペースがあることを確認します。
         vertices.setSize(4, Vertex());

         // 矩形領域のアタッチメントを構成する4つの頂点のワールド頂点位置を計算する。
         // これは、スロット (とアタッチメント) がアタッチされているボーンのワールドトランスフォームが、
         // レンダリング前にSkeleton::updateWorldTransform() によって計算されていることを
         // 想定しています。頂点の位置は、sizeof (Vertex) のストライドで、
         // 頂点配列に直接書き込まれます。
         regionAttachment->computeWorldVertices(slot->getBone(), &vertices.buffer().x, 0, sizeof(Vertex));

         // カラーとUVを頂点にコピーする
         for (size_t j = 0, l = 0; j < 4; j++, l+=2) {
            Vertex& vertex = vertices[j];
            vertex.color.set(tint);
            vertex.u = regionAttachment->getUVs()[l];
            vertex.v = regionAttachment->getUVs()[l + 1];
         }

         // インデックスを設定する、2つの三角形が四角形を形成する。
         indices = quadIndices;
      } else if (attachment->getRTTI().isExactly(MeshAttachment::rtti)) {
         // MeshAttachmentにキャストし、rendererObjectを取得して
         // ワールド頂点を計算できるようにする。
         MeshAttachment* mesh = (MeshAttachment*)attachment;

         // 頂点のための十分なスペースがあることを確認する。
         vertices.setSize(mesh->getWorldVerticesLength() / 2, Vertex());

         // エンジン固有のTextureは、ロード時にアタッチメントに割り当てられた
         // AtlasRegionに格納されています。これは、領域アタッチメントがマッピングされた画像を
         // 含むテクスチャアトラスページを表します。
         texture = (Texture*)((AtlasRegion*)mesh->getRendererObject())->page->getRendererObject();

         // メッシュアタッチメントを構成する頂点のワールド頂点位置を計算。
         // これは、スロット (とアタッチメント) がアタッチされているボーンのワールドトランスフォームが、
         // Skeleton::updateWorldTransform() によってレンダリング前に計算されていることを
         // 想定しています。頂点の位置は、sizeof(Vertex) のストライドで、
         // 直接頂点配列に書き込まれます。
         size_t numVertices = mesh->getWorldVerticesLength() / 2;
         mesh->computeWorldVertices(slot, 0, numVertices, vertices.buffer(), 0, sizeof(Vertex));

         // カラーとUVを頂点にコピーする
         for (size_t j = 0, l = 0; j < numVertices; j++, l+=2) {
            Vertex& vertex = vertices[j];
            vertex.color.set(tint);
            vertex.u = mesh->getUVs()[l];
            vertex.v = mesh->getUVs()[l + 1];
         }

         // インデックスを設定する、2つの三角形が四角形を形成する。
         indices = quadIndices;
      }

      // アタッチメント用に作成したメッシュを描画する
      engine_drawMesh(vertices, 0, vertexIndex, texture, engineBlendMode);
   }
}

この素朴な実装なら、すぐに使い始められるでしょう。しかし、最適化という点では、まだいくつかの簡単な方法があります:

  • engine_drawMeshは、レンダリングのためにメッシュをすぐにSubmitすることを想定しています。つまり、スケルトンのアタッチメントごとに1回のドローコールを発行します。プロダクション段階の実装では、すべてのメッシュを1つのメッシュにバッチする必要があります。理想的なのは、すべてのアタッチメントが同じテクスチャアトラスページとブレンドモードを使用しており、スケルトンのレンダリングは1回のドローコールで済む場合です。さらに、シーン内のすべてのスケルトンが同じテクスチャアトラスページとブレンドモードを共有する場合は、すべてのスケルトンを単一のメッシュにバッチすることもでき、ドローコールも単一になります。
  • 現在のセットアップでは、2色のティントには対応していません。
  • 現在のセットアップでは、クリッピングに対応していません。

上記のコード(および spine-cpp をベースとするすべての Spineランタイム)では、軽量なコンテナであるspine::Vectorを使用しています。標準的なC++ RTTIは重いので、独自のRTTIソリューションも実装することにしています。上記のコードでは、アタッチメントのタイプを区別するためにこのソリューションが使用されているのがわかると思います。