Writing Blender Plugin in C++

ここしばらく DCC ツール上のモデルをリアルタイムに Unity に同期する、MeshSync というツールを開発しています。その Blender 対応にとても苦労したので情報を残しておこうと思います。
なお、Blender の世界ではプラグインは "add-on" と呼ぶことが多いようですが、本記事では "プラグイン" で統一します。

Blender は Maya や 3ds Max のような、総合型の 3DCG の DCC ツールです。競合するツールはいくつかありますが、主要な DCC ツールの中で Blender は唯一オープンソースフリーソフトウェアなのが最もユニークな点だと思います。
Blender の大部分は C で書かれています。C++ ではなく、C です!Blender はコア部分以外は基本 python で機能を拡張していく構造になっています。UI や各種編集機能、fbx in/exporter など根幹をなす機能も python で実装されているものが多く、ゆえに python バインディングに絡む部分はよくメンテされています。一方で BlenderC/C++ 用のプラグイン API を用意していません。手の混んだ拡張をしたいなら fork して独自ビルドを作れ、ということなのでしょう。よって、Blender 用に拡張機能を作るときは普通は python で書くことになります。

MeshSync も最初は python で実装していたのですが、同期はできたものの絶望的に遅く、到底実用には耐えられないものでした。pythonスクリプト言語の中でも遅い部類でであり、頂点一つ一つに対して加工を施す、というようなローレベルな処理には向きません。頂点一つにアクセスするたびに python オブジェクトの生成/破棄が行われるため、C/C++ でアクセスするのと比べて数百倍とかのオーダーで遅くなります。しかもマルチスレッド処理もできません。
これをどうにかするには、C/C++ で直接 Blender のデータを扱うしかありません。加えて、既存のメジャーリリースの Blender で動かないと意味がないので fork & 独自ビルドの選択肢は取れず、Windows, Mac, Linux で動作させたい&長期的にサポートしたいのであまり Hacky な手も使いたくありません。…というのが今回のチャレンジでした。

いつも通り成果物は github で公開されているので、詳しく知りたい場合は同時にソースコードも参照ください。正直この記事だけでは理解が困難な部分もあるかと思われます。
https://github.com/unity3d-jp/MeshSync/tree/20181130/Plugin/MeshSyncClientBlender

基本戦略&事前準備

Blender の pyhton オブジェクトは内部的に C 側のデータへのポインタを保持しています。そのポインタから、データだけではなく python バインディングの型情報へもアクセスできるようになっており、python から呼べる関数やプロパティは C++ からも呼べます。これを使ってどうにかするのが基本戦略になります。

事前準備として、Blender をソースからビルドしてデバッグ実行できるようにしておきます。これは必須です。何かわからないことがあったらとりあえずデバッグ実行、関係ありそうな部分で break して動作を調べる必要があるためです。加えて、Blender はビルドの過程で自動生成されるソースが大量にあり (主に python バインディング)、その部分を調べるためにも一度はビルドしなければなりません。
幸いビルドは簡単です。wiki の Building_Blender の手順通りにやればすんなり通ります。
https://wiki.blender.org/wiki/Building_Blender/Windows
svn でライブラリのバイナリを取得、git でソースを取得、cmake でプロジェクトを生成して VisualStudio でビルド。でかい OSS は大抵手順通りやってもどっかで躓くものですが、Blender は何の問題もなくビルドできました。偉い。

一点、BlenderソースコードGPL であることは留意しておきましょう。Blender のデータ構造にアクセスするには Blenderソースコードの .h ファイルをコピペして使うなどが必要になりますが、そうするとプラグインGPL に感染します。Blender に依存する部分が小さいのであれば、同じデータ構造の struct を自力で定義することで対応できるかもしれません。しかし Blender はバージョンアップのたびにデータ構造も細かく変わっていて、追従するのがとても大変になります。MeshSync では素直に GPL を受け入れることにしましたが、DCC ツール依存部分とそれ以外を別ライブラリに分離して感染を最小限に抑えています。

C++ から Blender に触れる

前述の通り、python オブジェクトから Blender のデータポインタを得てどうにかしていくのが基本方針となります。(Blender のデータポインタについては、@vipper36 さんが解説を書いてくださいました。感謝。:https://qiita.com/vipper36/items/3e6012c3c770ade0d412 )
ほとんどの処理を C++ 側で行うため、最終的に MeshSync は python 側はほぼ UI 処理とコールバック登録だけになりました。コールバックは 2.79 では bpy.app.handlers.scene_update_post を使っています。これはシーンになにか変化があったときに呼ばれるコールバックで、live link が目的な今回はおあつらえ向きです。2.80 ではタイマーを使っています (後述)。 pythonC++ の橋渡しには pybind11 を使いました。

pythonBlender のオブジェクトを扱うときは大抵 bpy.context, bpy.data, bpy.scene の 3 つが起点になります。まずは C++ でこれらを扱えるようにします。
bpy.context は C 側では bContext に相当します。bContext は常にグローバルに一つ存在するオブジェクトです。bpy.data や bpy.scene なども bContext から取得でき、プラグイン側は初期化時に bContext を得ておけばあとは大体やりたいことはできます。

python バインディングの型情報は StructRNA というオブジェクトに収められています。bpy.context もこれを持っているので、bContext を得るときについでに StructRNA も得ておきましょう。StructRNA はリンクリストになっており、一つ StructRNA を得られればそこから全ての型を巡回できます。

// python から bpy.context を引数に呼ばれる想定 
void setup(pybind11::object bpy_context)
{
    auto rna = (BPy_StructRNA*)bpy_context.ptr();
    if (strcmp(rna->ob_base.ob_type->tp_name, "Context") != 0)
        return;

    auto first_type = (StructRNA*)&rna->ptr.type->cont;
    while (first_type->cont.prev)
        first_type = (StructRNA*)first_type->cont.prev; // 最初の StructRNA までリンクをたどる
    
    // ...
}

ちなみに StructRNA に限らず、Blender の主要なデータは大体リンクリストで繋がっています。実に C 的です。
StructRNA はメンバ関数 (StructRNA::functions, FunctionRNA) やプロパティ (StructRNA::cont.properties, PropertyRNA) の情報も持っています。これらもリンクリストになっています。

python 用の関数 (FunctionRNA) を呼ぶには、引数と戻り値用のスペースをまとめたメモリブロックを用意し、それと bContext と self となるポインタを引数として FunctionRNA::call を呼びます。このメモリブロックは 1 byte align でなければなりません (VisualStudio なら #pragma pack(push, 1)、gcc や clang なら __attribute__( ( packed ) ) を指定)。例えば、float と int を受け取って float を返す Object のメンバ関数を呼びたい場合以下のようになります。

static bContext *g_context;

float test_call(Object *self, FunctionRNA *f, float a1, int a2)
{
    PointerRNA ptr;
    ptr.data = self;

#pragma pack(push, 1) // VisualStudio
    struct {
        float a1;
        int a2;
        float ret;
    }
#pragma pack(pop)
    params = {a1, a2};
    ParameterList param_list;
    param_list.data = &params;

    f->call(g_context, nullptr, &ptr, &param_list);
    return params.ret;
}

template である程度一般化ができます。

static bContext *g_context;

template<class R> struct ret_holder
{
    using ret_t = R&;
    R r;
    R& get() { return r; }
};
template<> struct ret_holder<void>
{
    using ret_t = void;
    void get() {}
};

#pragma pack(push, 1)
template<typename R, typename A1, typename A2>
struct param_holder2
{
    A1 a1; A2 a2;
    ret_holder<R> ret;
    typename ret_holder<R>::ret_t get() { return ret.get(); }
};
#pragma pack(pop)

template<typename T, typename R, typename A1, typename A2>
R call(T *self, FunctionRNA *f, const A1& a1, const A2& a2)
{
    PointerRNA ptr;
    ptr.data = self;

    param_holder2<R, A1, A2> params = { a1, a2 };
    ParameterList param_list;
    param_list.data = &params;

    f->call(g_context, nullptr, &ptr, &param_list);
    return params.get();
}

…この書き方だと引数の数分 param_holder と call のバリーエーションを作る必要があり、もっとスマートに書けそうな気もしますが、今回はこれで妥協しています。
プロパティは引数が self だけの関数なので単純です。スカラ用の getter と配列用の getter が別に用意されています。

template<typename Self>
static inline int get_int(Self *self, PropertyRNA *prop)
{
    PointerRNA ptr;
    ptr.id.data = ptr.data = self;

    return ((IntPropertyRNA*)prop)->get(&ptr);
}

template<typename Self>
static inline float get_float(Self *self, PropertyRNA *prop)
{
    PointerRNA ptr;
    ptr.id.data = ptr.data = self;

    return ((FloatPropertyRNA*)prop)->get(&ptr);
}

template<typename Self>
static inline void get_float_array(Self *self, float *dst, PropertyRNA *prop)
{
    PointerRNA ptr;
    ptr.id.data = ptr.data = self;

    ((FloatPropertyRNA*)prop)->getarray(&ptr, dst);
}

これで関数呼び出しとプロパティの取得はできるようになりました。あとは必要な型とその関数/プロパティを文字列ベースで探して呼ぶだけです。

話を bpy.context, bpy.data, bpy.scene に戻しましょう。bpy.data は C 側では Main (注:型名です) に相当し、bpy.context.blend_data で取得できます。つまり名前が "Context" の StructRNA を探し、その中から名前が "blend_data" のプロパティを探し、最初に取得した bContext を self にしてプロパティを取得すればいいわけです。同様に bpy.scene も bpy.context.scene で取得できます。これで必要な処理を実装する準備が整いました。

一点、python バインディングが用意されてない関数を呼びたいケースがたまにあります。Blender のソースは多数のモジュールに分離されているため、必要なモジュールだけプラグインにリンクすればいいんじゃないかと考えたんですが、これは断念しました。多くのモジュールが他のモジュールへ依存関係を持っており、単純な関数一つ呼びたいだけでも大量のモジュールをリンクする必要が出てきて収集がつかなくなります。結局、単純な関数に限りそのソースをコピペして持ってくる、というのが一番現実的だという結論に至りました。

Blender の Outliner に表示されるオブジェクトは、大体 C 側では Object 型のインスタンスです。Object はトランスフォームを持ち、Object::data に Camera や Mesh などタイプ別データへのポインタが入っています。data のタイプは Object::type で判別でき、OB_MESH などの enum が定義されています。Unity で例えると Object が GameObject 相当品、Object::data が Component 相当品、というようなイメージです。
以降はこのタイプ別データを取得する手順になります。

Transform & Animation

トランスフォーム (TRS) を取得しようと思ったとき、DCC ツールは大抵個別の要素 (位置, 回転, スケール) を取得する API とグローバル行列を取得する API を用意しています。Blender もこの両方があります。(Object.matrix_world, Object.location, 等など) 取得したい情報はオブジェクトのローカルな位置や回転なので、個別要素の方を取得したくなります、が、これは泥沼へ続く道です。

Blender には delta と呼ばれる概念があります。delta は仮想的な親オブジェクトとして機能し、Global TRS = Local TRS * Delta TRS * Parent TRS となっています。個別要素を取得したい場合でもこの delta を考慮しなければなりません。加えて回転は色んなモードが用意されており (Eular XYZ, XZY ..., Axis Angle, Quaternion)、モードに応じた取得処理を書く必要があります。
話が Blender から逸れますが、Maya の場合、delta はありませんが代わりに offset, pivot, joint orientation といったまた独自の要素が絡んできます。単純に位置を取りたいだけでもこれら全てを考慮しなければ正しい結果は得られません。要するに、個別要素の取得は DCC ツール固有のトランスフォーム処理を正確に理解しておく必要があり、見た目よりもずっと難度が高いです。

一方、グローバル行列から取得する場合は話は単純です。グローバル行列は delta や constraints などが全て適用された結果であるため、DCC ツール固有処理について気にする必要はほぼありません (座標系や単位の違いくらい)。Blender でも Maya でも 3ds Max でもほぼ同じ処理で TRS を取得することができます。MeshSync で採用したのもこちらです。
今回欲しいのはオブジェクトのローカル TRS であるため、親を持つ場合はその逆行列を掛けてローカル化しています。行列からの TRS の取得の仕方は、ぐぐれば簡単に見つけられますが、MeshSync ではこんな処理になっています。

template<class T> inline tvec3<T> extract_position(const tmat4x4<T>& m)
{
    return (const tvec3<T>&)m[3];
}

template<class TMat>
inline tquat<typename TMat::scalar_t> extract_rotation_impl(const TMat& m_)
{
    using T = typename TMat::scalar_t;
    tmat3x3<T> m{
        normalize((tvec3<T>&)m_[0]),
        normalize((tvec3<T>&)m_[1]),
        normalize((tvec3<T>&)m_[2])
    };
    {
        auto s = extract_scale_sign(m_);
        m[0] *= s;
        m[1] *= s;
        m[2] *= s;
    }

    tquat<T> q;
    T tr, s;

    tr = T(0.25) * (T(1) + m[0][0] + m[1][1] + m[2][2]);

    if (tr > T(1e-4f)) {
        s = sqrt(tr);
        q[3] = (float)s;
        s = T(1) / (T(4) * s);
        q[0] = (m[1][2] - m[2][1]) * s;
        q[1] = (m[2][0] - m[0][2]) * s;
        q[2] = (m[0][1] - m[1][0]) * s;
    }
    else {
        if (m[0][0] > m[1][1] && m[0][0] > m[2][2]) {
            s = T(2) * sqrt(T(1) + m[0][0] - m[1][1] - m[2][2]);
            q[0] = T(0.25) * s;

            s = T(1) / s;
            q[3] = (m[1][2] - m[2][1]) * s;
            q[1] = (m[1][0] + m[0][1]) * s;
            q[2] = (m[2][0] + m[0][2]) * s;
        }
        else if (m[1][1] > m[2][2]) {
            s = T(2) * sqrt(T(1) + m[1][1] - m[0][0] - m[2][2]);
            q[1] = T(0.25) * s;

            s = T(1) / s;
            q[3] = (m[2][0] - m[0][2]) * s;
            q[0] = (m[1][0] + m[0][1]) * s;
            q[2] = (m[2][1] + m[1][2]) * s;
        }
        else {
            s = T(2) * sqrt(T(1) + m[2][2] - m[0][0] - m[1][1]);
            q[2] = T(0.25) * s;

            s = T(1) / s;
            q[3] = (m[0][1] - m[1][0]) * s;
            q[0] = (m[2][0] + m[0][2]) * s;
            q[1] = (m[2][1] + m[1][2]) * s;
        }
    }
    return normalize(q);
}

template<class TMat>
inline typename TMat::scalar_t extract_scale_sign(const TMat& m)
{
    using T = typename TMat::scalar_t;
    auto ax = (const tvec3<T>&)m[0];
    auto ay = (const tvec3<T>&)m[1];
    auto az = (const tvec3<T>&)m[2];
    return sign(dot(cross(ax, ay), az));
}
template<class TMat>
inline tvec3<typename TMat::scalar_t> extract_scale_signed_impl(const TMat& m)
{
    using T = typename TMat::scalar_t;
    auto s = extract_scale_sign(m);
    return tvec3<T>{ length(m[0]) * s, length(m[1]) * s, length(m[2]) * s };
}

ただ、グローバル行列を使う場合にも問題があって、スケールの符号が一部失われてしまいます。 XYZ 全て正、もしくは全て負の場合は正しく判別できるのですが、X だけ負のような場合にこれを復元する方法がなく、全て負と誤認されてしまいます。これは対応を諦めてそういう仕様としました。

アニメーションにも同じことが当てはまります。アニメーションを取得する場合、F-Curve を巡回して位置や回転を算出するのが正道に思えますが、これだと delta や constraints を自力でハンドリングしないといけないため、非常に難しいです。
F-Curve 巡回よりも、一定間隔で時間を進めつつ、各オブジェクトのグローバル行列から TRS を取得していく、いわゆるベイクの方が簡単かつ確実と言えます。MeshSync が採用したのもベイク方式です。

Camera & Light

これらは特に難しいことはありません。強いて注意点を挙げると、Camera の vertical fov は angle_y プロパティであること、Unity 互換にするには 90 度 X 軸回転を入れる必要があることくらいでしょうか。

Mesh

今回の主目的。python 側でも C 側でも型名は Mesh ですが、以下基本的に C 側の Mesh の話になります。
インデックスは Mesh::mloop (要素数は totloop) から取得できます。Blender では per-index のデータは "Loop" と呼ばれているようです。ポリゴン情報は Mesh::mpoly (要素数は totpoly) から取得できます。アサインされているマテリアルもここから取得できます。基本的な頂点情報は Mesh::mvert (要素数は totvert) から取得できます。

Mesh::mvert には法線も入っていますが、これは per-vertex の法線です。欲しいのは普通 per-index の法線のはずです。per-index の法線は Mesh::ldata に含まれます。ldata はレイヤーと呼ばれる追加の頂点データ郡を格納する場所になっており、UV や頂点カラーもここに格納されています。汎用的な分取得の手順も複雑で、レイヤーの種類と数からインデックスを得てデータポインタを得るという流れになります。(
BMesh::normals(), CustomData_get())
per-index の法線は未構築であったり現在の Mesh の状態と同期していないことがあります。python に公開されている Mesh.calc_normals_split() を呼ぶことによりこれを最新の状態に更新できます。この関数は既に最新の状態であれば何もしないので、毎回呼んでもパフォーマンス的な問題はないはずです。
頂点データは単なる生の配列なので並列処理が可能ですが、calc_normals_split() はマルチスレッド非対応のようです。

UV は python の UVLoopLayers という型 (StructRNA) の active プロパティから取得できます。UVLoopLayers は実体を持たないインターフェースのような型で、self として指定するのは Mesh 自身になります。UV は per-index です。頂点カラーは python の LoopColors という型の active プロパティから取得できます。処理内容は UV と同じです。頂点カラーも per-index です。(この二つは法線同様 Mesh::ldata に格納されているデータであり、ldata 経由でもアクセスできます)

Blendshape は Mesh::key->block に格納されています。最初の KeyBlock は基準 (basis) となる頂点で、以降は basis との差分と weight 値から移動量を求めます。

Skinning

Blender のスキニング処理は特殊で、知っていないととても苦労すると思います。
重要なポイントは、Bone は特殊な座標系で扱われているということです。以後この特殊な座標系を "Armature 座標系" と呼びます。
Blender の座標系は Z-up ですが、Armature 座標系は Y-up かつ Z が反転しています。つまり Armature 座標系と World 座標系を変換するには以下の行列を掛ける必要があります。

Armature to World

  1, 0, 0, 0,
  0, 0,-1, 0,
  0, 1, 0, 0,
  0, 0, 0, 1

World to Armature

  1, 0, 0, 0,
  0, 0, 1, 0,
  0,-1, 0, 0,
  0, 0, 0, 1

Blender で Outliner に表示されるオブジェクトのほとんどは Object のインスタンスですが、Bone は例外的に Object ではありません。Armature の中のみに存在できる特殊な型となっています。にもかかわらず、Object は Bone を親に持てます。つまり親が Object の時と Bone の時で別処理になります。このことは TRS 取得処理にも関わってきます。(ちなみに、Object は Bone のみならず特定の頂点を親にすることもできます。今回の私のケースではさすがに対応を諦めました…)

Object には bDeformGroup のリストである Object::defbase というデータがあり、bDeformGroup::name が影響を受ける Bone を示しています。頂点の Bone との関連付けは Mesh::dvert からアクセスできる MDeformVert から取得できます。

EditMesh

「頂点一つ動かすたびにそれをリアルタイムで同期する」が MeshSync の基本理念なので、編集中の Mesh も対応する必要がありました。
編集中の Mesh とは Blender 上で Edit モードの時の Mesh で、通常の Mesh とは全く違う扱いになっています。この編集中の Mesh、python からアクセスする手段がたぶん存在しません。つまりこの辺の処理はプラグインからのアクセスを考えていない無法地帯となっています。

Blender は、Mesh を編集する際、BMEditMesh という編集用のデータを用意します。例えば Cube を作成して選択し、Edit モードに入ったという状況を考えてみましょう。この場合、Edit モードに入った瞬間に元 Mesh から編集用の BMEditMesh オブジェクトが作成され、編集はそちらに適用されていきます。ビューポートに描画されるのも編集中は BMEditMesh です。Edit モードを抜けると BMEditMesh の内容が Mesh に適用され、BMEditMesh は破棄されます。つまり、Mesh の内容は編集中もずっと編集開始前のままです。編集中のモデルデータを取得したい場合、BMEditMesh の方のデータを見なければなりません。

BMEditMesh は Mesh::edit_btmesh で取得できます。編集中か否かは単純に Mesh::edit_btmesh が null かどうかで判別できます。Mesh::edit_btmesh->bm->vtable から頂点、Mesh::edit_btmesh->looptris からインデックスを取得できます。BMEditMesh は全てのポリゴンが三角形になっています。
法線は都度計算する必要があります。(BM_loop_calc_face_normal) UV は複雑で、Mesh::edit_btmesh->bm->ldata.layers[CD_MLOOPUV].offset で UV データへのオフセットが得られ、BMLoop::head.data + offset が UV データの位置になります。(BMLoop は Mesh::edit_btmesh->looptris の型です)

注意すべき点として、インデックスは未構築状態なことがあり、このときエクスポートしようとするとクラッシュを招きます。未構築のときは Mesh::edit_btmesh->bm->elem_table_dirty が真になっているので、一旦諦めて次の update を待ちましょう。再構築はビューポートに BMEditMesh を描画する処理の中で行われるため、有効なデータはすぐに来ます。

モディファイアの Bake

python メソッドの Object.to_mesh() を呼ぶと、Mesh を新規に作成してモディファイアを bake した結果を格納して返します。また、NURBS や 3D Font などの Mesh に変換可能なオブジェクトを Mesh 化することもできます。
to_mesh() で返ってきたオブジェクトは BlendDataMeshes.remove() を呼ばないと開放されない点に注意が必要です。怠るとすごい勢いでメモリを食いつぶすでしょう。

Blender 2.80

現在 beta の 2.80 シリーズですが、ソースコードレベルでも大きな変更が加わっています。が、幸い上記の Transform/Camera/Light/Mesh/BMesh 取得はほぼ変更なしでそのまま動きました。
変更が激しいのは私が把握している範囲で、SceneGraph 関連 (Scene::master_collection という Collection でオブジェクトが管理されるようになった)、Material (もはや原型を留めていない)、UI 関連などです。2.80 は現在進行系であちこち変更されているので、今詳しく触れるのは避けます。

一点、プラグイン開発者には暴挙と言うべき重大な変更があって、scene_update コールバック、および Object の is_updated が削除されています。
https://developer.blender.org/T47811
しょうがないので MeshSync は 2.80 ではタイマーを用いて定期的に全オブジェクトを巡回して 1 つ前のデータと比較、自力でデータ更新チェックを行っています。つまり、2.79 版とは比べ物にならないくらい遅いです。beta が取れるまでに scene_update が復活することを願います。

Conclusion

解説しきれていないことも多数あるのですが、力尽きた&重要な部分は大体書いたと思うので今回はここで切り上げます。
見ての通り C/C++Blenderプラグインを書くのは茨の道です。エンジニアの視点ですが、Blender が Maya 等と比べて致命的に弱い点がここだと感じます。しかも 2.80 ではここを改悪する方向に変更が加えられつつあります。とはいえ、今回の live link であったり、複雑な constraint や simulation を実装するような場合、労力を割いてでも C/C++ で書く価値はあるのではないかと思います。
近頃 Blender を採用するスタジオが増えてきているように見受けられますが、アーティスト以上に TA/エンジニアの方々が苦労されることになるのではないかと予想されます。関係者の方々の健闘を祈ります。

余談

MeshSync は現在 Blender の他に Maya, Maya LT, 3ds Max, Motion Builder, Metasequoia などもサポートしています。これらのツールから頂点等を抽出する処理を書きたい場合、MeshSync のソースコードが参考になるかもしれません。気が向いたら他のツールの解説も今後書こうと思います。
github.com