Spine runtime - 03.Draw & Clipping

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

UnityやUnrealEngineのコンポーネント使わない、生のSpineランタイムの取り扱い記事です。

ようやく、表示について触れたいと思います。Unityコンポーネントなどでは勝手にやってくれるため、何も考える必要がありませんが、自前の環境で動かしたいのであればポリゴンを使って頑張って貰う必要があります。 とはいえ、ポリゴンさえ使えればとりあえず表示できるので頑張って頂きたい。

変更点

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

前回、ver3.6 からの大きな変更点は、クリッピング 機能の追加です。 クリッピングは領域をマスクする機能です。 多角形で指定します。白黒濃淡画像で行う、ステンシルと言われる手法ではありません。

Spine におけるクリッピングはソフトウェアで行われます。

github.com

実際のコードはこんな感じ。画像処理をかじった人ならピンと来るかも知れません。 まるでポリゴンを描くときのスキャンライン法みたいなコードです。 その通りで、ポリゴンとクリッピングポリゴンの重なりを見て、削除、変形、分割を行っています。要するにソフトウェアで処理されています。

我々はステンンシルを用意する必要はありません。今までとほぼ同じコードで、クリッピングを実装できるのです。とても素晴らしいことです。 処理は公式でも 重いから覚悟しろよな! と言及されていますが、お手軽かつ環境を選ばない機能ですから、これを使わない手はありません。

今回はクリッピングの対応についても同時に触れたいと思います。

基本的な流れ

では表示、描画方法についてです。サンプルは前回ら引き続き、c++ の sfml を使った物を使用します。

ポリゴンについては、OpenGL , DirectX , webGL などなど、各自ご用意ください。この上記サンプルではマルチプラットフォーム統合ライブラリ sfml を使用しています。

Spine を表示する手順は以下の通りです。

  • アニメーションをセットしてステートの時間を進ませる(前回、前々回でやりました)
  • アタッチメント(画像とかメッシュ)情報を取得
  • 頂点計算用のバッファ(float array)を確保
  • 頂点計算する(computeWorldVertices)
  • 計算結果の頂点やUVをsfml形式に直して描画

となります。アタッチメント とは、Spineを構成する要素の最小単位、オブジェクトです。腕や顔パーツの画像がこれにあたります。そのほかに、IKなどの要素もこれに含まれますが、描画には不要なのでスキップします。また nullptr も飛んでくるので、ヌルチェックを忘れずに。

頂点計算用バッファはこちら(プログラム側)が malloc などで用意します。それをSpineに渡して計算結果を取得する流れになります。あらかじに skeleton->updateWorldTransform(); などで行列計算は済んでいるので、そこまで重い処理ではありません。が、メモリ確保がネックになります。バッファ長が超えないなら再利用するような、キャッシュ機構を用意しましょう。

重要な点はコールバック方式では無い点です。描画は能動的に行う必要があります。

それでは、実際のコードをみてみましょう。

ステート部分

main.cpp には無く、sfml 用に独自拡張した SkeletonDrawable クラスにあります。

github.com

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

この部分は前回と同様です。アニメーションをセットして、時間を進めて、この後に描画処理を行います。

描画部分

描画部分も同様に SkeletonDrawable クラスにあります。

github.com

ちょっと巨大すぎますので、まず構造だけ以下に抜き出します。

void SkeletonDrawable::draw() {
        // Early out if skeleton is invisible
        if (skeleton->getColor().a == 0) return;

        for (unsigned i = 0; i < skeleton->getSlots().size(); ++i) {
                Slot &slot = *skeleton->getDrawOrder()[i];
                Attachment *attachment = slot.getAttachment();
                if (!attachment) continue;

                // Early out if the slot color is 0 or the bone is not active
                if (slot.getColor().a == 0 || !slot.getBone().isActive()) {
                        clipper.clipEnd(slot);
                        continue;
                }

                if (attachment->getRTTI().isExactly(RegionAttachment::rtti)) {
                        // region
                        RegionAttachment *regionAttachment = (RegionAttachment *);

                } else if (attachment->getRTTI().isExactly(MeshAttachment::rtti)) {
                        // mesh
                        MeshAttachment *mesh = (MeshAttachment *) attachment;

                } else if (attachment->getRTTI().isExactly(ClippingAttachment::rtti)) {
                        // clipper
                        ClippingAttachment *clip = (ClippingAttachment *) 

                } else continue;

                
                clipper.clipEnd(slot);
        }
        clipper.clipEnd();
}

以上の単純な構造になっています。

slot

for (unsigned i = 0; i < skeleton->getSlots().size(); ++i) {
                Slot &slot = *skeleton->getDrawOrder()[i];

                // Early out if the slot color is 0 or the bone is not active
                if (slot.getColor().a == 0 || !slot.getBone().isActive()) {
                        clipper.clipEnd(slot);
                        continue;
                }
}

slot 複数の attachment(画像とか) が格納できるものです。まずそれを取得します。getDrawOrder で前後関係をソート済のものを取得します。

slot には不透明度や、不可視のフラグがあるので、それを元の表示をパスします。

最後の clipEnd(slot) は後ほど説明します。今はとりあえずループの最後に必ず付けることだけ覚えて置いてください。

attachment

        Attachment *attachment = slot.getAttachment();
        if (!attachment) continue;
        ...
        if (attachment->getRTTI().isExactly(RegionAttachment::rtti)) {
                // region
        }

表示する必要のある画像やメッシュを取り扱う者がこの attachment です。先ほどのソート済 slot から取得します。

判別方法がちょっと独特です。以前は attachment->type で判別できたと思うのですが、言語の機能で行うようになったようです。c++ では RTTI の機能を使っているようですが、c-sharp なら以下になります。

// c sharp
        var regionAttachment = attachment as RegionAttachment;
        if (regionAttachment != null) {
                // region
        }

as が使われています。なおc言語では attachment->typeenum AttachmentType が格納されています。

描画で扱う attachment は以下のものがあります。

type ---
RegionAttachment メッシュ分割されてない画像。四角形。
MeshAttachment メッシュ分割された画像。
ClippingAttachment クリッピング。開始時に挟まる。

ClippingAttachment が新しく増えました。

これら3つを場合分けして、ポリゴンに返還して表示という流れになります。

クリッピング

表示の前に、クリッピングの流れを説明します。

クリッピングは以下のようにソート済 slot,attachment に挟まってやってきます。

0 : region
1 : mesh
2 : mesh
3 : clipping
4 : mesh
5 : region
6 : mesh
7 : mesh

注意すべき点は、開始のみ挿入され、終了は挿入されない点です。 開始は以下のように clipStart を呼ぶだけ。

        ...
        } else if (attachment->getRTTI().isExactly(ClippingAttachment::rtti)) {
                // begin clipping
                ClippingAttachment *clip = (ClippingAttachment *) slot.getAttachment();
                clipper.clipStart(slot, clip);
                continue;
        } else {
        ...

では、どうやって終了を検知しているのでしょうか?その役割は clipEnd(slot) です。ループの終端や次に移るときに必ず入っているのが確認出来ます。

        if (slot.getColor().a == 0 || !slot.getBone().isActive()) {
                clipper.clipEnd(slot);
                continue;
        }

clipEnd(slot) ですが、内部を見てみましょう。

void SkeletonClipping::clipEnd(Slot &slot) {
        if (_clipAttachment != NULL && _clipAttachment->_endSlot == &slot._data) {
                clipEnd();
        }
}

終了時の slot なのかを検証しています。該当する場合、本当のクリッピング解除 clipEnd() を行います。なので、slot ごとに clipEnd(slot) を実行する必要があるのです。

この時利用する clipperSkeletonClipping クラスで、これは各自で生成してご用意ください。使い回すので、Spineオブジェクトに1つに対して1つ用意して、使い回します。

class SkeletonDrawable : public sf::Drawable {
public:
private:
        mutable SkeletonClipping clipper;
};

何をするクラスかは以下のコードが参考になります。

        if (clipper.isClipping()) {
                clipper.clipTriangles(worldVertices, *indices, *uvs, 2);
                vertices = &clipper.getClippedVertices();
                verticesCount = clipper.getClippedVertices().size() >> 1;
                ...
        }

clipTriangles に attachment のポリゴン情報を与えると、クリッピング後のポリゴン情報が返されます。このとき、ポリゴンは場合によって削除、追加されるため、変動 します。例えば、RegionAttachment は四角形のポリゴン(三角形2枚)ですが、クリップされると三角形1枚になったり、三角形4枚になったりします。後で説明しますが、これがクリッピング対応の一番の難関になります。

話は戻って、ループ終了後は、安全のためクリップを解除する clipEnd() を忘れずに。以下のような構造になります。

for (unsigned i = 0; i < skeleton->getSlots().size(); ++i) {
        Slot &slot = *skeleton->getDrawOrder()[i];
        Attachment *attachment = slot.getAttachment();
        if (!attachment) continue;      // !!!!!!!!!!!!!!!!!!

        // Early out if the slot color is 0 or the bone is not active
        if (slot.getColor().a == 0 || !slot.getBone().isActive()) {
                clipper.clipEnd(slot);  // clip
                continue;
        }
        ...
        clipper.clipEnd(slot);  // Clip
}
clipper.clipEnd();  // Safety. force disable clip

ん? attachment が無いときにやってませんね。これたぶんバグだと思います。ちゃんとやらないとバグります。

MeshAttachment

RegionAttachment(四角形)の前に、MeshAttachment の表示方法からやります。 Meshはポリゴンが何枚も並んだ構造をしています。Regionが三角形2枚に対し、Meshはn枚の三角形からなります。 クリッピングを行うとRagionも最終的にMeshになるので、先にやった方がいいんですよ。

サンプルのループは、内容をスタックして最適化しているためちょっとわかりづらい。テクスチャなどに変化が無いなら次のメッシュと結合、とかとかしてます。サンプルでは逆に不要なので、以下のように変更して説明します。

void SkeletonDrawable::draw() {
        // Early out if skeleton is invisible
        if (skeleton->getColor().a == 0) return;

        for (unsigned i = 0; i < skeleton->getSlots().size(); ++i) {
                Slot &slot = *skeleton->getDrawOrder()[i];
                Attachment *attachment = slot.getAttachment();
                if (!attachment){
                        clipper.clipEnd(slot);
                        continue;
                }

                // Early out if the slot color is 0 or the bone is not active
                if (slot.getColor().a == 0 || !slot.getBone().isActive()) {
                        clipper.clipEnd(slot);
                        continue;
                }

                if (attachment->getRTTI().isExactly(RegionAttachment::rtti)) {
                        // region
                        RegionAttachment *region = (RegionAttachment *) attachment;
                        drawRegion(slot,region);
                } else if (attachment->getRTTI().isExactly(MeshAttachment::rtti)) {
                        // mesh
                        MeshAttachment *mesh = (MeshAttachment *) attachment;
                        drawMesh(slot,mesh);
                } else if (attachment->getRTTI().isExactly(ClippingAttachment::rtti)) {
                        // clipper
                        ClippingAttachment *clip = (ClippingAttachment *)  attachment;
                        clipper.clipStart(slot, clip);
                        continue;       // do not need clipEnd(slot).
                }
                
                clipper.clipEnd(slot);
        }
        clipper.clipEnd();
}

Mesh,Region,Clippingごとに処理して、clipEnd(slot)を呼んでループエンドです。簡単ですね。

頂点計算 (computeWorldVertices)

まず、ポリゴン表示に必要な頂点情報(UVなど含む)の計算を行います。これにはまず計算結果を出力するバッファを私たちで用意する必要があります。

サンプルではこのあたりになります。

https://github.com/EsotericSoftware/spine-runtimes/blob/2031fe14dbf862951367155ef3a7a058d88d8047/spine-sfml/cpp/src/spine/spine-sfml.cpp#L136

関数に切り分けたので以下のようになるでしょうか。

Vector<float> worldVertices;

...

void drawMesh(Slot *slot, MeshAttachment *mesh){
        // buffer
        if (worldVertices.size() < mesh->getWorldVerticesLength()){
                worldVertices.setSize(mesh->getWorldVerticesLength(), 0);
        }
        // compute
        mesh->computeWorldVertices(slot, 0, mesh->getWorldVerticesLength(), worldVertices, 0, 2);
        // texture
        auto texture = (Texture *) ((AtlasRegion *) mesh->getRendererObject())->page->getRendererObject();
        // vertices
        auto vertices = &worldVertices;
        auto verticesCount = mesh->getWorldVerticesLength() >> 1;
        auto uvs = &mesh->getUVs();
        auto indices = &mesh->getTriangles();
        auto indicesCount = mesh->getTriangles().size();
}

worldVerticesVector 計算領域として用意しています。 領域確保(メモリ確保)はとても重い処理ですから、こんな感じで、小さければ拡張するというキャッシュ機構を挟んでおくのが良いでしょう。 次に計算して、その後、各種情報を変数に一時置き換えます。これは、後でクリッピングの際に内容をすり替える必要があるからです。

getWorldVerticesLength() でバッファに必要な長さを取得できます。単純に float の個数を返すので、頂点数は、x,yと2個セットで並んでいるので 1/2 したものになります。

テクスチャ情報は atlas の時に渡した値がここに入っています。sfml のテクスチャを渡したと思うので、それが返されます。

color

考慮すべき色情報は2つあります。skeleton->colorslot->color です。エディタ上ではこの2つの色を乗算しています。

mulcolor.argb = skeleton->getColor().argb * slot->getColor().argb;

0.0~1.0 の float 値なので、そのままかければ乗算合成できます。実際はこれに更にエンジン固有の色を乗算させることになると思います。

tintcolor(darkcolor)

Spineには明るくする色が存在します。tintcolor と呼ばれる物です。ランタイムでは darkColor という表記になっています。

if (slot->hasDarkColor()){
        Color darkColor = slot->getDarkColor();
}

これは sfml ではサポートしていないのか、コードにありません。また、c などではちょっと取得方法が異なります。

/// c
if (slot->darkColor != nullptr){
        Color darkColor = *slot->darkColor;
}

ポインタだったりします。言語ごとにちょっと異なるので気をつけてください。

この tintcolor は、単体だと スクリーン合成 です。が、単純に乗算色の結果にスクリーン色を加えているわけではありません。

オフィシャルでは UnityのShader で内部の式を見ることが出来ます。

github.com

return (texColor * i.vertexColor) + float4(((1-texColor.rgb) * _Black.rgb * texColor.a*_Color.a*i.vertexColor.a), 0);

BlendOPが one , oneMinus なので事前にalphaが乗算されています。そのあたりの事前処理を省いて単純な色の計算に変換すると

out.rgb = tetxure.rgb * mulcolor.rgb + (1.0 - texture.rgb) * darkcolor.rgb
out.a = texture.a * mulcolor.a

となります。ちょっと特殊ですね。

ポリゴン描画

では、ポリゴンに分割して描いてみましょう。

        // vertices
        auto vertices = &worldVertices;
        auto verticesCount = mesh->getWorldVerticesLength() >> 1;
        auto uvs = &mesh->getUVs();
        auto indices = &mesh->getTriangles();
        auto indicesCount = mesh->getTriangles().size();

という感じで先ほど用意しました。

ポリゴン情報は indices に3頂点1ポリゴンとして入っています。indicesCount を3で割れば、ポリゴン数が取得できるという寸法です。4頂点のポリゴンは無いので安心してください。 なので、以下のようなループでポリゴン1枚分の情報が取得できます。

for (auto i = 0; i < indicesCount/3; i++){
        sf::Vertex vertex[3];
        for (auto j = 0; j < 3; j++){
                auto index = indices[i];
                
                vertex[j].position.x = (*vertices)[index];
                vertex[j].position.y = (*vertices)[index + 1];
                vertex[j].texCoords.x = (*uvs)[index];
                vertex[j].texCoords.y = (*uvs)[index + 1];
                vertex[j].color.r = mulcolor.r * 255;
                vertex[j].color.g = mulcolor.g * 255;
                vertex[j].color.b = mulcolor.b * 255;
                vertex[j].color.a = mulcolor.a * 255;
        }
        CustomDrawOnePolygon(vertex,texture);
}

となります。

クリッピングの対応

ではクリッピングに対応させます。

まずこの attachment,slot がクリッピングの対象であるかを調べる必要があります。isClipping() で取得します。

        ...
        // vertices
        auto vertices = &worldVertices;
        auto verticesCount = mesh->getWorldVerticesLength() >> 1;
        auto uvs = &mesh->getUVs();
        auto indices = &mesh->getTriangles();
        auto indicesCount = mesh->getTriangles().size();
        // clipping
        if (clipper.isClipping()) {
                clipper.clipTriangles(worldVertices, *indices, *uvs, 2);
                vertices = &clipper.getClippedVertices();
                verticesCount = clipper.getClippedVertices().size() >> 1;
                uvs = &clipper.getClippedUVs();
                indices = &clipper.getClippedTriangles();
                indicesCount = clipper.getClippedTriangles().size();
        }
        // draw polygon
        ...

クリッピング対象であるなら、clipTriangles を使ってポリゴンに関する情報を再計算します。 その結果は clipper が返すので、各の変数に置き換えていきます。 あとはそのまま先ほどと同じく、クリッピング済の頂点情報をポリゴンに分割して表示して終わりです。

クリッピングの対応はメッシュであれば、簡単な変更ですぐ対応できます。ちょっと面倒になるのは次の RegionAttachment です。

RegionAttachment

RegionAttachment はメッシュ分割されていない、4角形の画像です。なので、頂点情報の数やポリゴンの頂点の並びが固定化しており、三角形2枚のポリゴンというものでした。

クリッピング機能の登場により、クリッピングを適用するとMeshと同じくn枚のポリゴンとなります。ポリゴンが増えたり減ったりする都合で、単純な4角形では無くなるのです。

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

よって、コードはほぼMeshと同じになります。サンプルを見てみると。

https://github.com/EsotericSoftware/spine-runtimes/blob/2031fe14dbf862951367155ef3a7a058d88d8047/spine-sfml/cpp/src/spine/spine-sfml.cpp#L118

        attachmentColor = &regionAttachment->getColor();

        // Early out if the slot color is 0
        if (attachmentColor->a == 0) {
                clipper.clipEnd(slot);
                continue;
        }

        worldVertices.setSize(8, 0);
        regionAttachment->computeWorldVertices(slot.getBone(), worldVertices, 0, 2);
        verticesCount = 4;
        uvs = &regionAttachment->getUVs();
        indices = &quadIndices;
        indicesCount = 6;
        texture = (Texture *) ((AtlasRegion *) regionAttachment->getRendererObject())->page->getRendererObject();

Meshと異なる点は3箇所。 computeWorldVerticesRegionAttachment 固有の物を使用すること。 計算用バッファの長さが 4x2=8 、ポリゴン数は2枚。 ポリゴンの頂点の並びを自前で用意する必要があることです。これはサンプルでは quadIndices にあたります。 Vector<unsigned short> で、クラス生成時に以下のように初期化されています。

        quadIndices.add(0);
        quadIndices.add(1);
        quadIndices.add(2);
        quadIndices.add(2);
        quadIndices.add(3);
        quadIndices.add(0);

verticesCountindicesCount も固定なのにわざわざ変数を代入して用意しています。これも結局は次のクリッピングコードを通すためです。Meshのように処理するなら以下のようになります。

        // vertices
        auto vertices = &worldVertices;
        auto verticesCount = 4;
        auto uvs = &region->getUVs();
        auto indices = &quadIndices;
        auto indicesCount = 6;
        // clipping
        if (clipper.isClipping()) {
                clipper.clipTriangles(worldVertices, *indices, *uvs, 2);
                vertices = &clipper.getClippedVertices();
                verticesCount = clipper.getClippedVertices().size() >> 1;
                uvs = &clipper.getClippedUVs();
                indices = &clipper.getClippedTriangles();
                indicesCount = clipper.getClippedTriangles().size();
        }

というわけで、クリッピングからのコードが、Meshと完全に同じになります。関数にまとめて共通化すると良いでしょう。

最後に

以上が描画の主な流れになります。クリピングが入りましたが、概ね変わっていません。 が、頂点の数やポリゴン構成が毎フレーム変わる という重要な仕様が増えました。 これは最適化を行う上でかなり厄介な物です。 3Dは普通、頂点の数は固定化し、マトリックスなどで変形を加える物ですから、根本が変動すると言うことは大きなコストが発生します。現に、Unityコンポーネントを覗くと、色々頑張っている痕跡が見て取れます。

ということで、描画とクリッピングでした。自前で実装するにはちょっと悩むかもしれませんね。

Spine runtime - 02.Animation

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

UnityやUnrealEngineのコンポーネント使わない、生のSpineランタイムの取り扱います第二回。コンポーネントを使えばらくちんですが、隠されている便利な機能もあります。役に立たないようで役に立つかもしれませんよ。

Spine runtime アニメーション編

次にアニメーションの説明をしたいとおもいます。アニメーション制御は ver3.6 あたりからほぼ変更はありません。使える機能が増えたことと、最初のkeyより前の扱いが明確に定義されました。

ちょうど公式にサンプルがあるので、軽く見てから読むとわかりやすいかもしれません。

ja.esotericsoftware.com

あまりプログラム側からは直接変更を加えず、SpineのIKなどの機能を使って、出来るだけアニメーション内で完結させるのが、開発者の想定している理想の開発モデルみたいです。 アニメーションを新規に生成する命令などを保有していますが、最終手段と言って良いでしょう。

アニメーショントラック

Spine は複数のアニメーションを合成できます。アニメーションにアニメーションを重ねるイメージです。

track[0] : idle
track[1] : walk
track[2] : aim
track[3] : empty

こんな感じで、この層を トラック(track) と言います。重ねると、デフォルトでは、アニメーションがある箇所のボーンやメッシュの値が上書きされます。なので、パーツごとに動きを作って重ねるのが基本になります。

トラックは特に気にする必要はありません。例えば、AnimationStateData クラスを介して以下のようにアニメーションを設定します。

animationState->setAnimation(0, "idle", true);
animationState->setAnimation(1, "walk", true);

第一引数がトラック番号です。何の下準備もなく指定して構いません。内部で勝手にトラック数を増やしたり、不要になったら減らしたりしてくれます。

かといって以下のようなことは止めましょうね。

animationState->setAnimation(0, "idle", true);
animationState->setAnimation(100, "walk", true);

この場合、1~99番目が無駄な領域になってしまいます。

TrackEntry の取得

アニメーションの操作は、TrackEntry を介して行われます。例えば、setAnimation 命令も、実は TrackEntry クラスが返されます。

auto track = animationState->setAnimation(0, "idle", true);

もちろん、現在再生中のアニメーションの情報も取得できます。

auto track = animationState->getCurrent(0);

が、タイミングによってどちらも同じ idel の情報が取得できるとは限らないので注意が必要です。

setAnimation

setAnimation はアニメーションを切り替えます。蓄えてあるキューを消してCurrentに置き換える とあるので、この命令の時点で切り替わっています。 直後に getCurrent で取得しても切り替わったアニメーション情報が取得できます。

...
setAnimation(0,idle)    track0:idle    result:idle
getCurrent(0)           track0:idle     result:idle
...
apply()                 track0:idle

が、後記しますが、getCurrent で変更を行う手法は危険な場合があるので、出来るだけ setAnimation の時点で変更を終了させましょう。

addAnimation

addAnimation は、アニメーションの終了(ループの終端)を待ってから、次のアニメーションに切り替える命令です。この場合は、切り替わるタイミングが不定期なため、getCurrent で操作することは基本的に出来ないと考えて良いでしょう。

addAnimation(0,walk)    track0:idle     result:walk
getCurrent(0)           track0:idle     result:idle
apply()                 track0:idle
getCurrent(0)           track0:idle     result:idle

基本的に、アニメーションを操作したい場合、setAnimation,addAnimation の段階でほぼ済ませましょう。getCurrent で操作するのは、トラックのブレンド率など、一部のパラメータ用とすべきです。

短いアニメーションの場合

TrackEntry ですが、生存時間がアニメーションによって様々なので、保持することは辞めましょう。 取得したら、次のSpineの命令を使うまでに変更を完了させないとクラッシュする恐れがあります。 それは、短いアニメーションであれば apply() で消えてしまう可能性があるからです。

...
setAnimation(0,stop)    track0:Stop    result:stop
apply()                 track0:null
getCurrent(0)           track0:null     result:null

このような場合、トラックは自動的に削除、解放されています。 参照するとメモリ読み込み違反です。

TrackEntry の操作

TrackEntry はプロパティを持っており、その値を操作することでアニメーションを制御します。

github.com

c++ なので getter,setter で書かれています。他の言語だと get,set は不要です。 主要なものを説明していきます。

アニメーション

        Animation* getAnimation();

現在のトラックで再生中のアニメーションのデータを参照できます。 名前を調べるにはこれを使ってください。

Loop

        bool getLoop();
        void setLoop(bool inValue);

ループ設定です。setAnimation でも指定できますが、ここでも指定できます。 再生中、途中でloopを外したりできます。

Delay

        float getDelay();
        void setDelay(float inValue);

delay は addAnimation のときに使用します。アニメーションが切り替わる時間を指定します。

TrackTime

        float getTrackTime();
        void setTrackTime(float inValue);
        float getAnimationTime();

トラックの再生経過時間です。アニメーション上の時間ではありません。 3秒のループアニメーションの場合、9秒などループ以上の値を返します。

ループを考慮した値は getAnimationTime を使ってくださいとのことです。

この値は自由に変更することができます。後の TimeScale=0 を組み合わせると、タイムストレッチが可能です。また逆再生も可能です。ただし、その場合はイベントを使用しないでください。

Track alpha

        float getAlpha();
        void setAlpha(float inValue);

アニメーションの合成率を設定できます。0~1.0のみならず、5.0や-1.0の負の値も許容します。もっとも使う要素だと思います。

        float getMixDuration();
        void setMixDuration(float inValue);

前回のアニメーションとのブレンド、遷移時間です。この値は setDefaultMix などで事前に定義するのが一般的ですが、ここで乗っ取ることができます。

これは非常に便利で、0.0を指定すれば即切り替わります。覚えておいて損はないでしょう。

Loop time

        float getAnimationStart();
        void setAnimationStart(float inValue);
        float getAnimationEnd();
        void setAnimationEnd(float inValue);
        float getAnimationLast();
        void setAnimationLast(float inValue);

なんとループ範囲を任意に設定できます。StartとEndをせっていします。 アニメーションを超えた時間を指定することもできます。

・・・あれ?3項目ありますね。 setAnimationLast が特殊なもので、説明は以下のようになっています。

The time in seconds this animation was last applied. Some timelines use this for one-time triggers. 
Eg, when this animation is applied, event timelines will fire all events between the animation last time (exclusive) and animation time(inclusive). 
Defaults to -1 to ensure triggers on frame 0 happen the first time this animation is applied.

このアニメーションが最後に適用された時間。一部のタイムラインは、1回限りのトリガーとして使用します。
例えば、このアニメーションを反映したとき、イベントは、AnimationLast(を含まない)から AnimationTime(含む)までのイベントを発生させます。
デフォルトでは、このアニメーションが適用されたときに、フレーム 0 のイベントが発生するように、-1が設定されています。

When changing the animation start time, it often makes sense to set TrackEntry.AnimationLast to the same value to
prevent timeline keys before the start time from triggering.

AnimationStart を変更する場合、AnimationLast を同じ値に設定して、
開始時間より前のイベントが発生しないようにします。

ふーん。ループの開始時をいじる場合、開始より前のイベントを発生させたい?その場合は AnimationLastを-1にしてね。そうじゃない場合は開始時間と同じにしてね。ということだそうです。

含む、含まない が逆なような気がしますが、合ってるのかな?

Time scale

        float getTimeScale();
        void setTimeScale(float inValue);

アニメーションの再生速度を変更できます。 0にすると停止します。

再生速度はほかの場所にもありますが、これはトラック(アニメーション)単位で出来ますよーというものです。主な使い方としては、0で停止させて、時間(setTrackTime)をいじって、タイムストレッチできます。

MixBlend

        MixBlend getMixBlend();
        void setMixBlend(MixBlend blend);

version3.7 の途中から正式にサポートされた機能です。アニメーションとアニメーションをどのように合成するかを選択できます。

mode ---
mixReplace 値を上書きします。
mixAdd 値を加算します。

デフォルトは mixReplace です。TrackAlpha が 100% なら上書き、50% なら半分ずつ合成されます。

A : animationA
B : animationB

out = A * (1.0-alpha) + B * alpha

alpha = 1.0 : out = A * 0.0 + B * 1.0 = B
alpha = 0.0 : out = A * 1.0 + B * 0.0 = A

では mixAdd とは何でしょう? これは単純にアニメーションを加算します。

out = A + B * alpha

alpha = 1.0 : out = A + B * 1.0 = A + B
alpha = 0.0 : out = A + B * 0.0 = A

イマイチ想像しにくいですね。ボーンの角度で考えてみましょう。

out = A.bone(30) + B(10) = 40

もとのアニメーション(A)を損なわずに、違うアニメーション(B)が合成できるモードです。もう一例、位置で考えてみましょう。

out.xy = A.bone.xy(20,0) + B.bone.xy(0,20) = xy(20,20)

右に移動するアニメーションと上に移動するアニメーションの合成です。結果は右上に移動します。

公式サンプルではこれになります。

ja.esotericsoftware.com

この手法は、3Dで昔は MorphTarget などで呼ばれたりもしていました。ボーンよりもメッシュの頂点の合成で使われ、主に表情の合成に使われます。

最後に言っておきます。まだ公式ランタイムではバグ持ちの機能です。そのうち治るといいですね。

EmptyAnimation

これを説明しなければなりません。Spine 特有のモーションです。

empty の名を冠する通り、空を意味します。Spine ではアニメーションの削除には `clearTrack' があります。

animationstate->setAnimation(0,"idle",true);
...
animationstate->clearTrack(0);

アニメーションの削除の意味するところは、元の形状(setup pose)に戻ることではありません。Spineのskeletonでも説明したように、boneはアニメーションがない限り、値の状態をキープし続けます。つまり、最後のアニメーションの状態を保って、動かなくなります。

そこで、元の形状に戻す命令がこの setEmptyAnimation , addEmptyAnimationです。

animationstate->setEmptyAnimation(0.25f);

説明するまでもありませんね。

empty 後

empty の後、トラックはどのようになるか理解しておく必要があります。setEmptyAnimation の後、そのトラックは empty という特殊なアニメーションに切り替わります。そしてボーンの姿勢が戻ったのち、削除され、そのトラックは nullptr になります。

setEmptyAnimation       track[0] : walk
...                     track[0] : empty
...
...                     track[0] : nullptr

この nullptr にちょっとした癖があるので注意してください。

nullptr 時の扱い

最初に setAnimation でアニメーションをセットしたとき、アニメーションがどうなったかよーく見てください。前のアニメーションと滑らかにミックスされましたか? 最初(nullptr) なのでされませんよね。

そうなんです、nullptr になると、アニメーションミックスされないんです。

これはバグではなく仕様であり、別に変な仕様でもありません。が、Spineのアニメーションミックスの機能を過信していると、empty からの繋ぎで、アニメーションが飛ぶような現象が発生することがあります。それはこの nullptr の仕業です。

実際に遭遇するのは先になるかと思いますが、先に解決だけ書いておきます。

解決策は簡単で、empty の代わりに、何も無い 空アニメーションをエディタで作っておきます。それを使えば解決されます。 空アニメーションがずっと再生されつづけるという状態になりますが、処理負荷はほぼ増えないので安心してください。

最後に

表示もまだの状態で込み入った話になってしまいましたが、spine のアニメーションはこんな感じになっています。今はふーんぐらいにとどめて、あとで必要になったら思い出してみてください。

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 の読み込み処理になります。

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

Spine Animationrig

Spine Animationrig

f:id:Ko-Ta:20191006195248p:plain
SpineAnimationrig01

ko-ta2142.github.io

こんなのつくりました。Spine を制御するツールです。Spineエディタより上の層、ランタイムを制御するためのツールとライブラリです。アニメーションを作るデザイナーさんもランタイムの機能を使ってより高度な制御が出来るアプリケーションです。

例えば、ループタイムを変えたり、4つのアニメーションを合成したり、任意のタイミングでアニメーションを切り替えたり、そんなことがプログラムを使わずに出来ちゃいます。

Unityでも頑張れば出来ます。けど、どんなときもUnityが使えるとは限りません。ちょっと動画が欲しい際などには大変役立ちますよ。

いきさつ

Spineを使って表情付け(facial rig)を行っていました。最初はテキストファイルに使用するアニメーションを定義して使っていましたが、兎に角、量が多いことやブレンド率やイージングなど、テキストで管理することが限界を迎えました。そこで、汎用的なタイムライン式のツールを作り始めました。公式にライセンスをお伺いしたところ

  • ランタイムをつかったアプリはライセンスの有無は問わない(ランタイムのソースコードを扱う当人はライセンスが必要)
  • ランタイムを使って画像、動画を出力しても構わない
  • ランタイムは用途に合わせて手を加えても構わない。
  • コピーライトと使用ライセンスの文言は入れること

とのこと。了承も取れたので公開しました。 物理計算使いたいなぁーとか、一連のアニメーションをくっつけて連番出力したいなぁーとか、きっと同じ悩みのひとに役立てると思います。

Spineって何?

Spineは、乱暴な言い方をすればキャラクターリグが出来るアニメーションツールです。主な機能として、ボーン変形、メッシュ変形を持っています。

ja.esotericsoftware.com

世の中、アニメーションツールで有名どころでは live2D , SpriteStudio , Moho , Flash などがあります。それぞれ一長一短がありますが、この中では live2D と Moho の中間ぐらいのツールかも知れません。公式の文言を借りれば、アクションゲームのキャラクターリグに特化したツールです。 全く違う使い方をしてめんなさい!

このツールの存在意義

話は戻ってこのツール、SpineAnimationrig についてです。 制御のために作った物ですが、ついでに、幾つか機能を付け加えました。大部分はSpineのランタイム機能(EntryTrack)を使いやすい形に落とし込んだ物ですが、どうしても欲しくて独自追加したものがあります。それが Physics(物理計算)です。

Spineには残念ながら、(現時点では)物理計算は搭載されておりません。いずれされるかも知れませんが、今のところは Unityなどゲームエンジンでやってください。 という方向性です。確かに、ちゃんと物理計算するには、ゲームエンジンとの連携は必須です。が、Spineはマルチプラットフォームであるが故に、そこまで干渉することは不可能です。

でも、そこまでちゃんとした物じゃ無くて良いんです。他の物体に干渉できなくて良いんです。ただちょっと、アニメーションの作成の補助、揺れを追加したいだけなんです。物理計算をエンジンに影響を与えないのであれば、マルチプラットフォームを維持できます。

f:id:Ko-Ta:20191006204247g:plain
Physics force

物理計算は組んだこと無かったので完璧ではありませんが、アニメーションを作るうえでは必要十分な物が出来ました。満足です。胸だってちゃんと揺れます :)

というわけで、良かったら使ってみてください。絵をちょっと動かすぐらいなら、live2Dより低コストに出来ます。Spineは必要になるので買ってください。Pro版を。

ko-ta2142.github.io

ja.esotericsoftware.com

おまけ:live2Dと違うところ

※そのうち記事を分離します。

よく比較対象に上がります。 現段階でのすごく乱暴な比較をすればこんな感じ。

name デフォーマ(ラティス) マルチボーン変形 物理計算 IK アニメーション管理
Spine no yes no yes no
live2D yes no yes no yes

個人的に大きな差異は、モデリングにおいてはデフォーマとマルチボーン、アニメーション作成においては物理計算、そしてSpineにはアニメーションを管理する機構はランタイム(プログラム)に丸投げという点です。

このなかでも、疑似3Dを行う場合は、live2Dの謳うデフォーマ(Blenderでは似たような物にラティスという変形があります)の有無は大きいです。 元の画像に何回変形が加えられるかで自由度が決まります。

変形回数

Spine
out = mesh*n + bone*n
live2D
out = mesh*n + bone*2 + deform*n

Spineはオーソドックスな3Dでよくあるモデルです。メッシュ変形とボーン変形から成ります。どちらも複数合成できます。

一方 live2D はボーン変形が決め打ちです。これはパーツ切りが大変だったり(ボーン内ボーンのよう扱いが出来るカーブという抜け道があったりしますが)とかなり制約を食います。 ただし、その後のデフォーマが複数適用できるので、これで調節してねといった作りです。

疑似3Dを表現する

疑似3Dを行う場合、両者で使う機能が異なります。

メッシュ変形 ボーン変形 デフォーマ
Spine やや 一般的 no
live2D no no 一般的

これは 出来ない ではなく、そのようにするべきかどうか、一般的かどうかです。 live2D はデフォーマに集約されています。これは何故でしょうか?

答えは、疑似3D以外にも変形は使われるからです。表情の変化であったり物理計算もこれに含まれます。 有限な資産(変形)を表現で食い合うわけです。 live2D はそこをデフォーマに絞り込むことで、メッシュ変形とボーン変形が自由に扱えます。アニメーションによっては、疑似3Dの要素として使用しても構いません。

さて、Spineですが、疑似3Dを行うと、その有限な資産が一気に減ります。 出来ればボーンでやりたいのですが、ボーンはそもそも動きのコントロールに使いたいところです。 3D用のボーンとコントロール分ければ良いんじゃねぇの?と言われればその通り。ただし、ボーンウエイトの原理について言及すべきです。

bone weight
weight = w1 + w2 + w3 + w4 = 1.0
out = v*w1 + v*w2 + v*w3 + v*w4

ボーンが分かれても重みは合計で1.0までと有限です。ここでも資産を食い合っているわけです。 この考えは良いところまで行っていると思います。ウエイトのグループを複数持つことが出来れば、解決できるでしょう。

weight1 = w1 + w2 =1.0
weight2 = w3 + w4 = 1.0
out = v*w1 + v*w2 + v*w3 + v*w4

これなら可能性があるはずです。

ということで、Spineの場合、疑似3Dの他用 にはあまり向いていません。背景を疑似3Dにするには十分なツールです。ですが、良く動くキャラに疑似3Dを適用するには向いていません。何かを犠牲にする必要が出てきます。 今後のアップデートで、Blenderにおけるラティスが実装されれば、この局面も打破できるでしょう。

物理計算

live2Dは思ってたのと違う……と最初思うかも知れませんが、有ることはとても大事です。というのも揺れを手付けすることは、上手い下手にかかわらず、かなりの労力を食います。

live2Dの物理計算は パラメータの動き で管理されます。位置の移動量ではありません。縦に振ったから縦に力が働いたりしません。なので、空の物理計算用パラメータを作って、そこから力を発生させるとかそんな使い方をすることもあります。

一方Spineには有りません。これは結構残念な点です。 そこで、この SpineAnimationrig をお使いください。もっともスタンダートなボーンの位置の変化から計算します。なので、大雑把に作って大雑把に動いてくれます。

アニメーション管理ツール

Spineのもう一つの欠点がコレです。Spineのランタイムがコレに当たりますが、プログラムにお任せしています。 Spineランタイムは良く出来ていて、プログラムを扱う側からするととても簡単で、無駄も無く、効率よく制御できます。 が、プログラミングが必要であり、タイミングもプログラム制御、Unityという環境を手に入れないとタイムラインで操作できません。(プレビューできる範囲にも制限があります)。

ゲームのちっちゃなキャラとしてはそれで十分だと思いますが、芝居などで数十個のアニメーション管理が必要になったら、SpineAnimationrig を試してみてください。 きっとお力になれるはずです。

multi-core threading


昔にも書いたマルチコアスレッドコードです。マルチコアによる分散処理を行います。
長らく修正を反映出来ていなかったのでgithubに上げてまとめました。

github

  • github multi-core and muti-threading

https://github.com/Ko-Ta2142/MultiCore


簡単なマルチコアスレッディング管理用クラスです。
マルチコアとマルチスレッドの違いは、処理をコア(論理)に割り当ててるかどうかどうかだけの違いです。
コアに割り当てない設定も可能ですが使う機会は無いでしょう。
性能については、60fpsでの使用に耐えうるモノで、開始と待機の精度はそれほど高くありませんが、待機のCPU使用率が低めの、バランス型です。
もっと精度が欲しい場合はsleep(0)でポーリングすればいいだけなのでこのライブラリは要らないと思います。


このライブラリはWindowsXPで動作するように、XPまでの命令で組まれています。
なので、物理コアと論理コアの判別出来ず、OSに委ねられています。

マルチコア処理

マルチコアによる分散処理を行うには、基本的にスレッドをコアに割り当てるだけ。
windowsの場合は割り当てるAPIは2つあります。

  • SetThreadAffinityMask

https://msdn.microsoft.com/ja-jp/library/cc429346.aspx

DWORD SetThreadAffinityMask (
  HANDLE hThread,             // 操作対象スレッドのハンドル
  DWORD dwThreadAffinityMask  // スレッドアフィニティマスク
);
  • SetThreadIdealProcessor

https://msdn.microsoft.com/ja-jp/library/cc429348.aspx

DWORD SetThreadIdealProcessor(
  HANDLE hThread,         // スレッドのハンドル
  DWORD dwIdealProcessor  // 理想的なプロセッサ番号
);

SetThreadIdealProcessorがよく使われます。
スレッドを論理コア番号に紐付けし、使用中であれば他の空きコアを試みるので、4/8コアのような一部を使う場合にとても素直で賢い挙動を行ってくれます。
ほぼこちらの使用で問題ないでしょう。
もしCPUコアの全てを使用したい場合は、SetThreadAffinityMaskが良いでしょう。
これはスレッドに使用しても良いコア番号をビットマスクで指定します。つまり4コアなら、1,2,4,8とすることで全てのコアに振り分けます。
1+2=3で1と2コアの両方の使用を許可出来ます。使い方としては良い案が浮かびませんが、今は1物理コア2論理コアのハイパースレッディングが主なので、物理コア単位にすると安定するかも知れません。


このライブラリではCPUのコアをほぼ全部使う場合以外はSetThreadIdealProcessorを使うようになっています。

初期化

使い方は簡単です。
初期化はグローバル関数を呼ぶだけです。生成されるスレッド管理クラスは、1アプリケーションにつき1つだけのシングルトンです。
アプリケーション終了時やコア数を変えるときは、解放関数を呼んでください。

  // initialize
  _MultiCoreInitialize(4);  // 実際の生成スレッド数はCPUコア数の最大値に丸め込まれます
  corecount := _MultiCoreManager.Count;
...
  // finalize
  _MultiCoreFinalize;

タスクの登録

マルチスレッド処理の場合、処理を関数にまとめて、その関数ポインタとして管理クラスに登録します。
登録なのでまだ実行はされません。

  for i:=0 to TaskCount-1 do
    _MultiCoreManager.Add( MultiCore_HorizonBlur , @TaskRecord[i] );

与える関数で気をつけるところはマルチスレッドプログラミングと同じです。順序を伴う処理は含まないこと、読み込みは良いけど書き込みは他と重複しないこと、あたりは守る必要があります。
また、与える関数ポインタには、自由に使えるポインタ型の引数が備わっており、タスクに渡したい変数内容などはポインタ(アドレス)にして受け渡します。
上記のように構造体を定義して渡すのが一般的です。

type
  TMultiCoreData = record
    // scanline area
    StartIndex,EndIndex : integer;
  end;
  PMultiCoreData = ^TMultiCoreData;

実行と同期

タスクが登録出来たら実行、そして大事なのが同期です。
全ての処理の完了を待つ必要があります。

  _MultiCoreManager.Start; // start execute task
  _MultiCoreManager.Sync;  // sync all task finished

Startの後にすぐSyncを呼ぶ必要はありません。待ち時間にメインスレッドでなにか処理をさせたいなら間に処理を挟むと良いでしょう。


以上がマルチコア処理の1サイクルです。

仮想負荷値を与える

このライブラリにはシンプルな負荷によるタスクの振り分け機能を持っています。

  _MultiCoreManager.Add( hogefunc,@hogerecord , 100 );

例えば、重い処理と軽い処理があった場合、重さが均等になるようコアに割り当てられます。

core0 : [task][task][task]
core1 : [task][task][hevytask]
core2 : [task][hevytask][task]
core3 : [task][task][hevytask]

イメージとしてはこのように構築します。指定無しはweight:1とされます。


だいたいタスクは均等になるように調整して渡すことになるので、この機能は有用そうに見えますが、実際使うことは無いでしょう……。
パフォーマンスを求めるならコア数に対して平等に処理を分割するコードを書くので尚更不要です。

待機と同期精度のお話

昔のバージョンもこれとあまり大きな変化はありませんが、何が変わったかというとタスクが無い状態での待機処理です。
割と面倒なのがこの待機処理だったりします……。


普通の使用なら、CPU使用率は度外視できるので、セマフォで同期を取ってタスク開始フラグをポーリングするだけでOKなので簡単です。
また、1回の処理限定なら、終わったらスレッドをそのままterminate(終わり)させておけば何の問題もありません。
しかし60fps環境で使用する場合は要求がちょっとタイトです。

  • 16ms周期で使用されるためスレッドは使い回すこと。
  • start/syncの応答精度が早い、個々のスレッドの待機と復帰が早いこと。
  • 待機状態でのCPU使用率が低いこと。(負荷ではなく使用率です)

の3条件です。


上でも出たフラグポーリングですが、よくあるのが以下のようなコードです。

  while true do
  begin
    semaphore.lock;
    f := StartFlag;
    semaphore.unlock;
    if f then break;
    // sleep
    sleep(1);   // sleep(0);
  end;

StartFlagがtrueになったら抜けるだけですがsleepが問題です。
応答を早めたいならsleep(0)を使いたいところですが、実際はCPU使用率が恐ろしく跳ね上がるのでsleep(1)が妥協点です。
sleep(1)の場合1msで同期が終わるかというと、スレッド間の微妙な誤差もあるので倍の2msぐらいに膨れ上がることは考慮しなければなりません。
更にstartとsyncでそれぞれ判定が入ることを考えれば、最大2+2=4ms。
更にwindowsは3msが最小単位だとかなんとかあるので、もしかすると3+3=6msになるかも。
60fpsで動作するなら、約16msのうち4ms(6ms)が消えるのは痛い出費です。


というわけで、実際のコードはちょっと変な感じですが、semaphoreであるクリティカルセクションの二重ロック(デッドロック)による永久待ちを基本にしています。

// main thread
  _MultiCoreManager.Start; ( wait.unlock )
  _MultiCoreManager.Sync; **1

// sub thread
  wait.lock;  // lock 1
  while true do
  begin
    wait.lock;  // lock 2. wait.
    wait.unlock;  // lock 1-1=0
    // flag check
    if (flagcheck) then break;
    ...
    sleep(0);
  end;
  TaskExecute;
  wait.lock;  // lock=1 **1

ただし、この方法は不完全ですり抜けることがあります。
そこですり抜けからデッドロックされるまでのちょっとの間だけ、精度の高いsleep(0)によるフラグポーリングを行います。

あれ?

マウスでぐりぐりする最初の1回目だけ処理時間が40msとかやたらかかることがあります。
この症状、どうやらクリティカルセクションの挙動によるところのようです。
待ち時間が短いとキビキビ動くんですが、待ち時間が長いと判定を甘くして地球に優しいモードになるようです。

  • InitializeCriticalSectionAndSpinCount - spin count

https://msdn.microsoft.com/ja-jp/library/cc429225.aspx
スピンカウントの値で調整してくださいね!と言うことですが、単位が不明なので正直解らないです:-Q
だいたい500ms過ぎたら寝る感じなので、60fpsなりで動く分にはキビキビ動くので、このあたりは妥協しましょう……。
尚、CPU負荷率が常に高い場合だとこの待機時間が緩和されます。このあたりはハードウェアな領域なのでこれ以上足を突っ込まない方が良さそうです。


ちなみに、普通は2でデッドロック解除待ちになりますが、任意の数値にも出来るそうです。
各スレッドごとにセマフォ持つよりは一本化する方が良い結果が出そうなのでそのうちやってみますが、更新が無かったら……期待値は得られなかったと言うことで。

暈かし処理

いつか触れようと思いますが、サンプルの暈かし処理には "prefix sum" という、値を加算して蓄積していく手法を使用しています。
原理は以下のページが詳しいです。動画に至っては英語では無いですが、図解でなんとなくわかるかと思います。

  • prefix sum

https://en.wikipedia.org/wiki/Prefix_sum

  • 2d sum quaries

https://www.youtube.com/watch?v=hqOqr6vFPp8
サンプルは横方向しか暈かしていませんので1次元です。ボックスの左端と右端を読み込めば、差分でボックス内の合計値がわかるという優れものです。
暈かしのボックスサイズが変わっても一律の処理負荷で行うアルゴリズムとして有名です。
shaderでやるならnVIDIAのサンプルを見るのが良いでしょう。

https://docs.nvidia.com/gameworks/index.html#gameworkslibrary/graphicssamples/d3d_samples/d3dcomputefiltersample.htm
下ごしらえに加えて、RGBの深度ビット数が増える、ハードウェアなら浮動小数バッファが必要になるので、ちょっと要求が高いのが難点ですが、暈かしをリアルタイムで出来るようになったのは昨今の進化を感じます。

UndoRedoのメモリの最適化


今までundo機能をなぁなぁで組んでいたら(全シリアライズデータをそのまま保持)メモリ使用量がヤバいことになったのでまじめに組み直してました。
というわけでundoとそれに使用するメモリ量の最適化のお話です。
エディタでも作らない限りundoredoのお世話になることは無いと思いますが……。

git hub

https://github.com/Ko-Ta2142/UndoRedo

まずはサンプル。
サンプルは簡単なペイントソフトでundoredoが可能です。
本題はお絵かきではなくて、キャンバスを128x128のセルで分割し、セルのシリアライズデータ(まんまピクセルデータ)をundoデータとして出力、内容の重複を検知して共有化、メモリ使用量が減る、といった一連の流れになります。
スクリーンショットだと、だいたい60〜80%ぐらいの削減が出来ています。


根幹のライブラリコードは20KB程度ととても小さい&依存性が無いので移植も簡単な内容です。
サンプルのペイントアプリのコードの方がよっぽど巨大です。(GUIアプリは仕方ないよね)

まずundoとredo

記憶するデータは、大きく分けると二種類あると思います。行動を記憶する手法(アクション)か、復元データを記憶(シリアライズ)していく手法です。
アクションの方が容量がコンパクトになる傾向ですが、undo処理そのものが編集対象に大きく依存したり、処理がかなり複雑・大規模になるので、とりあえずSaveLoadで保存と復元が可能なシリアライズデータを扱う方法で行きます。
圧縮も今回の本題では無いのでパスします。今回の最適化処理を施した後にかけるのが、容量的にも速度的にも理想的です。


シリアライズ方式であれば原理はとても単純です。作業が進めばデータをpushしていくだけ。前後の差分を気にする必要もありません。
ちょっと気を遣うとすれば、追加時に「変化が無ければ追加しない」を組み込んでおくのが現実的です。同じデータを追加させない機能があるだけで、undoredoの管理は雲泥の差で楽ちんになります。

  if UndoAdd(data) then
    // 変化があったのでundoに追加された
  else
    // 同一データなので変化無し

動作で気をつける点があるとすれば、undoのデータ保持上限を超えた場合(古いデータを破棄)、undo後に編集を行った場合(redo用に残っている前方のデータを破棄)の2つはデータの削除が発生します。

[redo data 3] (delete)
[redo data 2] (delete)
[*now] (edit!)
[undo data 1]
[undo data 0]

undo管理クラスがデータの解放まで行う都合上、データは編集オブジェクトとの関係が完全に分離されているのが望ましいでしょう。
データの解放にオブジェクトの参照が必要な構造は、オブジェクトの変化があるエディタではちょっと危険です。

シリアライズデータを小分けにする

シリアライズ方式の欠点は、サイズ・容量が大きくなりがちなことです。圧縮でなんとかなる場合もありますが、画像データなどはあまり期待出来ません。
またサイズが大きいと言うことはそれだけ生成負荷も高くなる傾向にあります。特にメモリ確保の負荷は大きくなるでしょう。
そこで、今後の布石としてまずはシリアライズデータを小分けにします。アニメーションデータ1つ!ならそれを素材や構造ごとに分けましょう。

(animation object)
  [image0 data]
  [image1 data]
  [sound0 data]
  [motion0 data]
  [motion1 data]
  [motion2 data]
...

分けただけではメモリ使用量は減ったりしてくれませんが、データの変更部分の特定、そしてなによりシリアライズデータの生成コスト(負荷)を分散、軽くすることに役立ちます。
また、小分けにしたことで生成の最適化も以下のような感じで簡単に組み込むことが出来ます。

procedure AnimationMotionClass.MakeSerialize();
begin
  if not(ChangeFlag) return;
  // make serialize data
  ...
  ChangeFlag = false;
end;
function AnimationMotionClass.SetTime(time:float);
begin
  MotionTime := time;
  ChangeFlag = true;
end;

変更が無ければ作り直さないよ。というフラグを噛ましておく常套手段が有効でしょう。
シリアライズデータの生成は未圧縮でもそこそこ重い処理なので、分散と生成の最適化(キャッシュ)は速度面できわめて重要です。

データ(メモリ)サイズの最適化

undoデータはその性質上、全く変更されていないデータの部分が大半を占めることになります。

(undo->)
[MotionData0]*---------*****
[MotionData1]---*******----*
[MotionData2]---------------
pass : -
changed : *

こんなかんじで大半が1つ前の同一のデータが並びます。
1つ前との差分方式などXOR比較など様々な方式が思い浮かびますが、比較速度、複製速度、扱いやすさ(取り出し)で同一データの場合は共有化させることで落ち着きました。
上の例なら以下のような感じに共有化します。

[data0]*---------*****
[data0]011111111123456
number : data class

実装も極力シンプルに、メモリ上のバイナリデータ(MemoryStream)から最適化されたクラスを取得、あとはそれを介してバイナリにアクセスするだけです。

  CacheClear;
  CacheSerializeData := object.GetSerializeData;
  CacheUndoData := MemoryOptimizePool.Get(CacheSerializeData); // get optimize data class

データを保持するクラスは自身の複製を作ったら参照カウンタを+1、解放されたら-1、0で実際に解放するよくある構造です。
ガベージコレクトというよりwindowsのcomみたいなヤツですね。

// replicate 複製
function MemoryBlock.Replicate();
begin
  AddRef();
  return(self);
end;

procedure MemoryBlock.AddRef();
begin
  RefCount++;
end;
procedure MemoryBlock.Release();
begin
  RefCount--;
  if RefCount = 0 then self.Free;
end;

複製と解放のコストがほぼ0(カウンタを+-1するだけ)なので、大量に複製する事になるundoに向いてます。
また、(共有化済み)データの比較がアドレス比較で済むので、内容に変化があったか(undoに追加して良いか)を検知が限りなく低コストで済みます。
嬉しいですね。

procedure MemoryBlock.Compare(other:TMemoryBlock):boolean; inline;
begin
  // self === other
  Result := pointer(self) = pointer(other);
end;

わざわざ関数作ってやることでは無いですが。

管理プールについて

共有化されたメモリデータはプールに登録、管理されます。

  // create
  pool := TmemoryBlockPool.Create;
  // get block from memory pointer
  block := pool.GetBlock(ptr,size);
  // free block
  pool.FreeBlock(block);
  // free
  pool.Free;

基本はシングルトンで動き、あのオブジェクトのデータもこのオブジェクトのデータもなんでも突っ込んで、雑に使って良いように設計されています。
無駄に重複しそうなものがあれば、undo以外のモノをぶち込んでも問題ありません。


雑に使っても良いようにプールとしてはそれなりに多いデータを保持することになります。だいたい1万個ぐらいは想定の範囲でなければなりません。
データの登録削除もそれなりに早くて、かつ検索が早い方式として、BinaryTree(二分木)を使用しています。
二分木と言っても、「同値を入れることが出来る」「削除が可能」なものが必要で、ちょっとアルゴリズムが複雑です。
ライブラリに無ければ、リニア(ふつうのarray配列)でも十分な速度が出てたので、それでもいいかなと思います。

最後に

ツールを作っていると億劫になりがち(出来れば実装したくないなぁ)なundoredoですが、わりと楽出来るように落とし込めたと思います。
オブジェクトの方にシリアライズ出力を書くのが億劫ではあるんですが、undoredoについてはマルチプラットフォーム(ビッグ、リトルエディアント問題)を無視出来るので(データを他に持っていったりしないので)、既にSaveLoadがあればそのデータを使って、無ければ雑にバイナリに書き込むのを用意して楽するのが良いと思います。雑で。

spine morph-target

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


ver3.6のspineを弄っているとメッシュデフォームがあるものの、口パクや表情でよく使うモーフターゲット(morph-target)がないので、固定的なものは力業でなんとかなるとしても、動的な表情変化などの実現が厳しいなぁと気づくと思います。
なんとかしたいですよねナナチ。
幸いランタイムのソースコードがフルオープンなので中を覗けますし、(ライセンスの範囲内で)勝手に弄ってもOKなので、なんとかできないかなーと休日眺めてみました。

サンプル


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

  • 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