Spine runtime - 01.Setup

Spine runtime

f:id:Ko-Ta:20191028181359p:plain

Spine についてです。エディタでは無く、プログラムに組み込むための ランタイム について書こうと思います。Unityプラグインではなく、もっと下層の部分です。 数年前にも記事にしましたが、Spineのバージョンも進みました。大きく変わっていませんが、細かいところに変更も入っているので、version 3.8 にて書き直したいと思います。

Spine

こちらになります。詳しくは説明しません。もう持ってますよね?

ja.esotericsoftware.com

ランタイム

プログラムからSpineを読み込んで表示させるために必要な物です。ただし、Spineのランタイムは マルチプラットフォーム 前提で組まれており、依存性が無く、標準ライブラリでコンパイルできるように作られています。

つまり、ポリゴン、点や線、サウンドが無い標準的な環境下でコンパイルさせるために、これらの機能が省かれています。私たちは、これらの機能を自分で作って、そしてランタイムと連係して、はじめて画面に表示することが出来るのです。

大変ですね。なので、普通は Unity とか UnrealEngine とか、提供されている便利なコンポーネントを使いますが、ここではそんな恵まれた環境ではない方向けのお話になります。

でも、まずは感覚を掴むために、Unityコンポーネント を使ってみるのをお勧めします。

ファイルのExport

Spineはエディタ用とランタイム用でファイル形式が異なります。まず、ランタイムで使用できるファイルを出力しましょう。公式、SpineBoyを出力すれば、以下3ファイルを得ることが出来ます。

spineboy-pro.json
spineboy-pro.atlas.txt
spineboy-pro.png

アニメーションデータ(json)、パーツ構造(atlas.txt)、画像(png)となります。-proはプロバージョンを表しますが、ランタイムでは関係ありません。エディタのみ、プロバージョンとエントリーバージョンが存在します。

バイナリ形式も出力できますが、json形式が推奨されています。実際の所、読み込み速度も米粒レベル未満の差なので、バイナリ形式の利点は薄いです。

ファイルのバージョン互換性

SpineでExportされたランタイム用のファイルは、残念ながら 後方互換性を持ちません。これはかなり厄介なので覚えておきましょう。追加機能が来たから最新にしてやるぜ!ということを簡単に行えないのが非常に残念な点です。

ですが、一応、バージョンがリリースになれば、そのバージョンに関しては互換性は維持されます。 現在、version 3.8 までがリリース、*version 3.9 がベータです。 エディタのバージョンが 3.8 であれば、ランタイムの 3.8 の 最新版 で安全に読み込めます。 リリースが、もうファイル形式弄らないよ。という宣言ですね。

サンプルプログラム

Spineのランタイムとサンプルプログラムは github からいつでもダウンロードすることが出来ます。

github.com

さて、いっぱいありますが、ランタイムを知る上でどのサンプルプログラムが良いでしょうか? 色々あるのですが、C#はUnityなので、ランタイムの構造を知るにはちょっと規模が大きいのと、Unity独自のセオリーが邪魔をします。

お勧めはC++でしょうか。実際にポリゴンで表示するところまで書かれている sfml を使ったサンプルがお勧めです。これをつかって説明していきます。

spine-runtimes/spine-sfml/cpp at 3.8 · EsotericSoftware/spine-runtimes · GitHub

ランタイムを直接扱うような人は、きっと C++ か C ぐらいなものですよね :)

ビルド

まずはビルドに成功してください。 上記のURLの Readme.md に書かれています。 具体的には CMake を使って、VSのプロジェクトファイルと、sfmlのソース一式をダウンロードして準備します。あとはVSで開いてコンパイルします。 なお、サンプルは32bitですが、64bitでもランタイムは問題なくコンパイルできます。構造を知るだけなら32bitで十分です。

まずはじめに

Spineのランタイムは依存性の無いライブラリです。これから依存性を与えていくことになります。ポリゴンなどのグラフィックもそうですが、メモリ確保とファイルIOもその1つです。これら依存を最初に定義する必要があります。それが Extention.h/cpp です。

spine-runtimes/Extension.h at 3.8 · EsotericSoftware/spine-runtimes · GitHub

class SP_API SpineExtension {
    ...
public:
    /// Implement this function to use your own memory allocator
    virtual void *_alloc(size_t size, const char *file, int line) = 0;
    virtual void *_calloc(size_t size, const char *file, int line) = 0;
    virtual void *_realloc(void *ptr, size_t size, const char *file, int line) = 0;
    /// If you provide a spineAllocFunc, you should also provide a spineFreeFunc
    virtual void _free(void *mem, const char *file, int line) = 0;
    virtual char *_readFile(const String &path, int *length) = 0;
};

この SpineExtension を使って(派生させて)、中身をあなたの環境に書き換える必要があります。 と、それもなかなか大変なので、実は下の方に DefaultSpineExtension というのが用意されています。特に問題が無ければコレを使えばOKです。

_readFile についてですが、ランタイムにはファイルデータをメモリから読み込む機能があります。実際のアプリケーションでは、パッケージングや暗号化を行う都合上、ファイルから読み込むことはありません。なので、_readFile は使用されることが無いのであれば、中身の無い nullptr を返すだけのコードでも構いません。

c の場合も触れておきます。c だとちょっと違います。

spine-runtimes/extension.h at fc0a5df0db99916286047ab2577da7332be47bb4 · EsotericSoftware/spine-runtimes · GitHub

void _spAtlasPage_createTexture (spAtlasPage* self, const char* path);
void _spAtlasPage_disposeTexture (spAtlasPage* self);
char* _spUtil_readFile (const char* path, int* length);

c の場合はクラスはないので、ヘッダにグローバルな関数が定義されています。が、実装部分がありません。実装部分は直接書いてくださいという手法になっています。関数ポインタ指定ではないのでちょっと戸惑いますよね。また、mallocなどは定義済で、ファイルIOとTexture読み込みのみとなっています。

spine-runtimes/spine-sfml.cpp at fc0a5df0db99916286047ab2577da7332be47bb4 · EsotericSoftware/spine-runtimes · GitHub

こんな感じで、cは直接実装部を書くスタイルになっています。

さて、c++ での SpineExtention の用意は以下のようになっています。

        // extention
        DebugExtension dbgExtension(SpineExtension::getInstance());
        SpineExtension::setInstance(&dbgExtension);
SpineExtension *SpineExtension::getInstance() {
    if (!_instance) _instance = spine::getDefaultExtension();
    assert(_instance);

    return _instance;
}

DebugExtensionというログとメモリ解放ミスを検知するクラスで包んでいますが、SpineExtension::getInstance()でデフォルトで用意された物取得して使用しています。

読み込み

Spineファイルを読み込みます。読み込みはまず atlasファイルを読み込んでから、アニメーション関係の jsonを読み込みます。

main.cpp の testcase() 関数に書かれています。

spine-runtimes/main.cpp at f619a972cbde796d5f03a44056807fb873d82db7 · EsotericSoftware/spine-runtimes · GitHub

void testcase (...) {
    SFMLTextureLoader textureLoader;

    auto atlas = make_unique<Atlas>(atlasName, &textureLoader);

    auto skeletonData = readSkeletonJsonData(jsonName, atlas.get(), scale);
    func(skeletonData.get(), atlas.get());

    // binary file format
    // skeletonData = readSkeletonBinaryData(binaryName, atlas.get(), scale);
    // func(skeletonData.get(), atlas.get());
}

なんですが、実はエラーが出ます。ランタイムの変更が main.cpp にまだ反映されていないみたいです。そのうち直されると思いますが、一応ここで修正を上げておきます。

 // auto atlas = make_unique<Atlas>(atlasName, &textureLoader);
    spine::Atlas *pAtlas = nullptr;
    {
        spine::String sAtlasName(atlasName);
        TextureLoader *pTextureLoader = &textureLoader;
        pAtlas = new spine::Atlas(sAtlasName, pTextureLoader, true);
    }
    unique_ptr<Atlas> atlas(pAtlas);

変更点は二点。make_unique が正しく動かないことと、TextureLoader へ渡す文字列は spine::String に変更されたことです。make_unique は先頭で再定義されてるのでそれが原因かと思いますが、重要ではないので触れません。

勿論、メモリ上から読み込む関数 readSkeletonData() も用意されています。詳細については今回は省略します。

atlas(画像読み込み)

まず atlas ファイル、画像の読み込みからです。unique_ptrは無視して、spine::TextureLoader を派生したクラスを生成して渡しています。中身はなんてことない、load()とunload()しかないクラスです。c では先ほど _spAtlasPage_createTexture() として出てきましたね。

namespace spine {
    class SP_API TextureLoader : public SpineObject {
    public:
        virtual void load(AtlasPage& page, const String& path) = 0;
        virtual void unload(void* texture) = 0;
    };
}

見慣れない=0は他の言語で言う abstract です。実態が無いので派生クラスで実装します。ここでいう派生クラスが SFMLTextureLoader です。抜粋すると、

void SFMLTextureLoader::load(AtlasPage &page, const String &path) {
    Texture *texture = new Texture();
    if (!texture->loadFromFile(path.buffer())) return;

    if (page.magFilter == TextureFilter_Linear) texture->setSmooth(true);
    if (page.uWrap == TextureWrap_Repeat && page.vWrap == TextureWrap_Repeat) texture->setRepeated(true);

    page.setRendererObject(texture);
    Vector2u size = texture->getSize();
    page.width = size.x;
    page.height = size.y;
}

void SFMLTextureLoader::unload(void *texture) {
    delete (Texture *) texture;
}

となっています。 pathには読み込むべき画像のファイルパスが設定されています。これは TextureLoader 時に指定した atlasName に、atlasファイルパスに書かれている画像ファイル名 spineboy-pro.png を足した物です。なので data/hogehoge/ に格納した場合は以下になります。

atlasName : data/hogehoge/sample.atlas.txt
path : data/hogehoge/sample.png

pageはテクスチャ情報などを記憶しておく物で、テクスチャクラス、サイズ情報を書き加えて返還しています。unload()時にはこれらを元に解放してください。

このテクスチャ読み込みは、Spineによって1枚だったり、2枚だったりします。2枚なら二回呼ばれます。

特に難しい加代はありませんが、1つ注意点があるとすれば、ここの Stringspine::String です。c++は他の言語と違い独自のstringを使用しているので注意してください。文字コードUTF8 のcharです。2バイトUnicode(utf16)ではありません。

Json SkeletonData(データ読み込み)

アニメーションに関する情報を読み込みます。

shared_ptr<SkeletonData> readSkeletonJsonData (const String& filename, Atlas* atlas, float scale) {
    SkeletonJson json(atlas);
    json.setScale(scale);
    auto skeletonData = json.readSkeletonDataFile(filename);
    if (!skeletonData) {
        printf("%s\n", json.getError().buffer());
        exit(0);
    }
    return shared_ptr<SkeletonData>(skeletonData);
}

2段階に手順が分かれています。まず json ファイルの解析用に SkeletonJson を使用、読み込みます。次に、実際のアニメーション時に使用する SkeletonData へ変換します。SkeletonJson はもう使わないので解放してください。

json.setScale() について補足しておきます。これはサイズを調整するものですが、データそのものに手を加えます。Spineエディタ上では x:100 であれば、スケールが0.01の場合は x:1 になります。普通の使い方では使用しない(1倍)方が賢明です。これが使用されているのはUnityの場合です。Unityは座標系は 1/100 が基準なので、それに合わせて縮小されています。Unityで値を拾うと小さくなっていて数値が合わない???ってハマるので覚えておいてください。

Skeleton AnimationStateData(状態クラス)

読み込みが完了しましたが、アニメーションさせるには状態を管理するステートクラスが必要になります。AnimationStateData , Skeleton の2つが必要になります。 この2つを生成していきましょう。

 SkeletonDrawable drawable(skeletonData);
    drawable.timeScale = 1;
    drawable.setUsePremultipliedAlpha(true);

残念。実際のコードは SkeletonDrawable という、sfml で spine を表示する独自定義したクラスの中にあるようです。

skeleton = new(__FILE__, __LINE__) Skeleton(skeletonData);
stateData = new(__FILE__, __LINE__) AnimationStateData(skeletonData);

デバッグ用のコードが入ってますが無視して、それぞれアニメーションデータである SkeletonData を元に生成するだけです。簡単ですね。

Skeleton

Skeleton はボーンの情報を保持するクラスです。Spineエディタで作ったボーンやアタッチメントがそのままこの中に入っています。これをプログラムから自由にいじることができます。いじったボーンはアニメーションがない限り、値はそのまま保持され続けます。

Spineのサンプルにあるような、プログラムから変更を行うものはほぼ9割以上この Skeleton を介して行われています。

AnimationStateData

アニメーション、およびトラック情報を保持します。アニメーションの詳細は次に説明しますが、Spineでは複数のアニメーションを何個も重ねることができます。これは動きを分けて管理することができ、アニメーション作成の負担を減らします。(なんですが、ランタイムの機能であり、エディタではこれができません。というわけで作ったのが SpineAnimationrig です。)これら複雑なアニメーションの管理を行います。

プログラムからはアニメーションの追加と削除、そして再生中のアニメーションを操作するトラック(track)の取得などに使用します。アニメーションそのものに変更を加えたりなど、高度な操作を行うことはまずないでしょう。

この AnimationStateData , Skeleton ですが、ここから SkeletonData クラスの内部データへアクセスする参照を持っています。なので、実際プログラムではこの2つを介して全ての操作を行います。SkeletonData は、解放してはいけませんが、private空間に押し込んでしまって構いません。

さて、これでアニメーションさせる手順は整いました。

  • atlas + texture
  • SkeletonData
  • AnimationStateData
  • Skeleton

の1+3クラスが動作に最低限必要な構成となります。

更新 アップデート

Spineの1フレームでの動作は以下の通りになります。

 void SkeletonDrawable::update(float deltaTime) {
    skeleton->update(deltaTime);
    state->update(deltaTime);
    state->apply(*skeleton);
    skeleton->updateWorldTransform();
}

delttime は1秒を 1.0f とした値です。60fpsならば 1/60 になります。 これを毎フレーム実行するだけでアニメーションします。勿論、表示は別ですよ。

ではそれぞれの役割を説明します。

skeleton->update()

昔は無かったと思います。実は、今のところ何もしていません。経過時間だけを記憶しているようです。今後使用される可能性もあるので、呼んでおきましょう。

state->update()

アニメーションの時間を進めます。もうちょっと詳しく言うと、アニメーショントラックの時間を進めます。

時間を進めるだけで、アニメーションの切り替えは次の state->apply で行われます。ここが曲者で、アニメーションの切り替え(setAnimation)の反映はこの時点では行われません。実行キューに追加されて、スタンバイに入ります。つまり、一度に大きく時間を進めた場合(スキップなど)、アニメーションの開始時間で誤差が出る可能性があります。これについてはまた書きたいと思いますが、今のところは毎フレームの頻度で呼ぶことを心がけましょう。

state->apply()

アニメーションの処理を行い、内容を skeleton に反映させます。skeleton はキャラクターのボーン情報を持った物で、アニメーションをもとにボーンの角度や位置を反映(上書き)していきます。上書き です。後々重要になるので覚えておいてください。

1つ注意して欲しいのが、ここでは個々のボーンの角度や位置しか反映されません。つまり、rootからの変形行列もskeletonは保持していますが、反映されるのは updateWorldTransform で行われます。

また、apply では IK制御や メッシュ変形 も行われます。他の更新命令と比べると 最も重い処理 を行うため、何回も呼ばないように気を付けてください。

skeleton->updateWorldTransform()

ボーンの位置や角度から、変形行列を生成します。なので、実はそんなに重くありません。頂点の計算もここでは行われません。頂点の計算は次の記事で説明します。

ボーン変更するならどのタイミング?

state->apply()の後で上書きすればOKです。

state->apply();
bone->setRotate(30);
skeleton->updateWorldTransform()

ただし、アニメーションの結果に値の変更を加えたい場合は一筋縄ではありません。例えば、現在の値に30度足す場合は以下のようになります。

bone->setRotate(bone->getRotate() + 30);

これは場合によって期待通りに動きません。それは、基本的にボーンの情報は保持され続けるためです。毎フレームごとに30度加算され続けます。(アニメーションが存在していれば state->apply() で上書きされます)

というわけで、どこかで値を初期化する必要があります。アニメーションで値が設定される前、つまり state->apply() の前が最適です。

 skeleton->update(deltaTime);
    state->update(deltaTime);
    preparePhysics();  // 事前に物理計算ボーンを初期位置に上書き
    state->apply(*skeleton); // アニメーションの値が上書きされる
    skeleton->updateWorldTransform();
    ApplyPhysics(); // skeletonの現在の値に加算する
    skeleton->updateWorldTransform();

上記は物理計算を反映させる場合の一例です。 物理計算はボーンの行列が必要になるため updateWorldTransform() が2回呼ばれています。物理計算は重い物なのです。

最後に

基本的な Spine の読み込み処理になります。

が、まだアニメーションも制御していなければ、表示すらできていません。 そのあたりを次の記事で説明します。