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コンポーネントを覗くと、色々頑張っている痕跡が見て取れます。

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