【MotionBuilder】UnboundWrapper とエラー対策

に公開

はじめに

今回の記事では、MotionBuilder の Python SDK 開発における UnboundWrapper、およびそのエラーに焦点を当てて解説します。

前半の1章・2章で、Python の基本的な知識と MotionBuilder における重要な Python Wrapper の動作を解説します。また、後半の3章で、UnboundWrapperError の発生ケースと対策例を紹介します。


1. 前提知識

まずは、この記事の内容の理解に役立つ、Python 関連の知識について解説します。

1.1. 変数とオブジェクト

Python における 変数 は参照に用いられるラベル、言わばただの「名前」であり、実際に値や型を持つのは オブジェクト です。

a = 7
b = a

例えばこの2行の処理は、大まかに流れを書くと

  1. 7を持つ新規の 整数オブジェクト が作られる
  2. その整数オブジェクトを、変数a(名前「a」)が参照する
  3. 変数b(名前「b」)が、変数aの参照する整数オブジェクトを同じく参照する

であり、結果を図示すると以下のようになります。

alt text
2つの変数が同一のオブジェクトを参照

上図では、名前 「a, b」が他のオブジェクトを参照しないよう、整数オブジェクトが自身に結び付けているように見えるでしょう。この時、オブジェクトは2つの名前を 束縛するname binding)といい、この束縛済みの名前は del 文で解放(unbind)させることができます。

del a

これにより、変数aは該当の整数オブジェクトを参照しなくなり、print(a)を実行するとエラーになります。ただし、これは整数オブジェクトが削除されたのではなく、下図に示すように変数aによる参照が取り除かれただけです[1]

alt text
変数aからの参照が消え、参照カウントが減った


では、del aではなくa = 13とした場合はどうでしょうか。

a = 7
b = a
a = 13

この場合、変数への新規オブジェクトの再代入が行われ、元のオブジェクトから代入時に指定した新規オブジェクトへと変数の参照先が切り替わります

alt text
オブジェクトの再代入により、変数aは別オブジェクトを参照するようになった

補足・参考
  • 補足:参照カウントについて
    Python は、各オブジェクトについて関数等から参照されている数を記録しており、この数を参照カウントといいます。参照カウントの値が0になった時、つまりオブジェクトが何にも参照されなくなった時、Python はこのオブジェクトに使われていたメモリを解放します(gc - garbage collection)。



1.2. 名前空間

Python における 名前空間 は、とある範囲(スコープ)内にある名前がそれぞれ一意のオブジェクトを指すための『名前とオブジェクトとの対応』を表す概念です。

組み込み関数 globals() は現在のモジュールのグローバルな名前空間の内容を表す 辞書 を返します。

# MotionBuidler 起動後、Python Editor で実行
import pprint
pprint.pprint(globals())

上記実行後の Python Editor には多くの『名前 : オブジェクト』 の一覧が表示されているはずです。ここからtest_var = 3など、新たな定義を行った後に再度実行すると、定義した名前「test_var」とその参照するオブジェクトの組が追加されていることが確認できます。

alt text
Python Editor の名前空間に新たに追加された名前

関数やクラス、モジュールも独自の名前空間を持ち、特定の関数内といったローカルな名前空間の内容を出力するにはlocals()を使います。

補足・参考
  • 補足:del 文の挙動について
    前節で紹介したdel文は、実際には名前空間から指定した名前を削除するものです。結果として、オブジェクトの名前の束縛が解かれることになります。

  • 補足:関数内でのグローバル変数の使用について
    グローバル変数の名前は、関数内というローカルな名前空間には登録されていないので、関数の中でグローバル変数を用いるには、変数名の前にglobalを書かなければなりません。

  • 参考
    Python 言語リファレンス - 9.2. Python のスコープと名前空間, 7.5. del 文


1.3. C++ の Python への公開

とある開発ソースを別の言語から利用できるようにすることを Wrap と言い、そのような目的で実装されたデータ構造・ライブラリ等を Wrapper(ラッパー)と呼びます。MotionBuilder の Python SDK も Wrapper であり、C++ で書かれた Open Reality SDK を Python で扱えるようにしたものです[2]

この Wrapper の作成には Boost.Python というライブラリが使用されています。

// Boost.Python のサンプル
// クラスと、その public なメンバ関数を Python に公開する

#include <boost/python.hpp>
#include <string>

// Python に公開するクラス
class Person
{
public:
    Person(std::string name, int age) : name(name), age(age) {}

    // 公開するメンバ関数
    std::string greet() const
    {
        return "Hello, my name is " + name + ". I am " + std::to_string(age) + " years old.";
    }

// private なメンバは Python には公開されない
private:
    std::string name;
    int age;
};

// このマクロの引数はモジュール名になる
BOOST_PYTHON_MODULE(boost_python_sample)
{
    using namespace boost::python;
    class_<Person>("Person", init<std::string, int>())
        .def("greet", &Person::greet);
}

このソースからビルドしたboost_python_sample.pydモジュールは以下のように動作します。

import boost_python_sample
print(dir(boost_python_sample))
# ['Person', '__doc__', '__file__', '__loader__', '__name__', '__package__', '__spec__']

from boost_python_sample import Person
print(dir(Person))
# ['__class__', '__delattr__', '__dict__', '__dir__', '__doc__', '__eq__', '__format__', '__ge__', '__getattribute__', '__getstate__', '__gt__', '__hash__', '__init__', '__init_subclass__', '__instance_size__', '__le__', '__lt__', '__module__', '__ne__', '__new__', '__reduce__', '__reduce_ex__', '__repr__', '__setattr__', '__sizeof__', '__str__', '__subclasshook__', '__weakref__', 'greet']

person = Person("test", 18)
print(type(person))
# <class 'boost_python_sample.Person'>

person.greet()
# 'Hello, my name is test. I am 18 years old.'

print(type(person.greet()))
# <class 'str'>


MotionBuilder のbinディレクトリ下にはpyfbsdk.pydがあり、これが Boost.Python を用いてビルドされた Python SDK の本体です。from pyfbsdk import ~と宣言した際にインポートされるのがこのモジュールになります。

補足
  • 「did not match C++ signatures」エラー

    Boost.Python 特有のエラーで、スクリプト中のメソッド・クラス等のシグネチャが Wrap 元の C++ での宣言と異なることを示すエラーです。

    alt text
    典型的なエラー: FBMessageBox()の最後の引数を忘れる


  • 公開の際のクラス名の指定

    この節で示した Boost.Python の例について、

    BOOST_PYTHON_MODULE(boost_python_sample)
    {
        using namespace boost::python;
    
        // Python 側でのクラス名を "PyPerson" にする
        class_<Person>("PyPerson", init<std::string, int>())
            .def("greet", &Person::greet);
    }
    

    とすれば、Wrap 元とは異なる名前でクラスを Python に公開することもできます。


  • Python Wapprer の実装について

    MotionBuilder において、Open Reality SDK を Python から使用する際の詳細な実装を知りたい方は、Help - Customizing the MotionBuilder SDK を参照ください。ちなみに、関数のみの利用といった、非常に限定的な用途であればこの Help 記載の手法に倣う必要はなく、下記リポジトリの方法で単純に実装することも可能です。

https://github.com/Ndgt/MBPluginLoader


2. Python SDK での Wrapper

この章では、MotionBuilder の SDK における Wrapper 関連についての解説と、重要な UnboundWrapper のエラーについて書きます。

2.1. SDK の構造の解釈

MotionBuilder の Python SDK は Open Reality SDK に包含される形で図示されることがありますが[3]、公式ヘルプやヘッダファイルの記述などから、実際は以下の様な関係で成立していると考えられます。

alt text
例: FBModelでの Wrap 構造

Open Reality SDK を直接利用しているのではなく、Open Reality SDK のクラスの『ラッパークラス <ClassName>_Wrapper』が存在し、それが Boost.Python で元のクラスと同名の Python クラスとして Python 側に公開されている、という構造です。

補足・参考
  • 補足

    もちろん、<ClassName>_Wrapperクラスも Open Reality SDK の一部だと解釈できますが、Open Reality Reference Guide に掲載されているクラス一覧には含まれていません。あくまで Python SDK の実装の一部として考えた方が理解しやすいと思います。



2.2. <ClassName>_Wrapperクラス

前節で登場した<ClassName>_Wrapperクラスは、以下の特徴を持ちます。

  • 対応する Open Reality SDK のクラス型のポインタを内部データとして保持する
  • 対応する Open Reality SDK のクラスと同じ構造を持つ
  • 各メンバ関数・メンバについてboost::python::object型に対応させる

Python SDK 使用時は、以下のように<ClassName>_Wrapperクラスを介して Open Reality SDK のデータに間接的にアクセスしている形になります。

alt text
Python SDK からのデータへのアクセスのイメージ

例えば、Python からFBModelCubeクラスのオブジェクトのName属性を使用した場合、

  1. FBModelCube_Wrapperクラスインスタンスの Property にアクセス
  2. ラッパークラスが保持するポインタからFBModelCubeクラスインスタンスにアクセス
  3. FBModelCubeクラスインスタンスのNameProperty から、Cube の名称を表す C++ の文字列を取得
  4. ラッパークラスの Property がその文字列をboost::python::object型として返す
  5. Python 側でstr型として受け取る

という流れで処理されます。

ところで、この記事や公式ヘルプでは『(Python) Wrapper』という言葉が度々登場します。この言葉が具体的な構造を指している場合は、<ClassName>_Wrapperクラス、またそれが Python に公開された Python クラスのオブジェクトを指していると理解すると良いでしょう。

参考


2.3. UnboundWrapperError

前置きが非常に長くなりましたが、今回の記事のテーマである重要なエラーの発生の仕組みについて解説します。

from pyfbsdk import FBModelCube
cube = FBModelCube("Sample Cube")

簡単な例として、Scene 内に Cube を作成する場合を考えましょう。上記実行後は、

  • 変数cubeが参照するFBModelCube型の Python Wrapper
  • MotionBuilder の Scene が管理するFBModelCube型オブジェクト

が生成されており、この時のオブジェクトの関係を図示すると以下の通りです。

alt text
Python SDK による Cube 作成時のイメージ


この状態からdel文で「cube」を名前空間から削除すると、Python Wrapper は破棄されうる一方で、MotionBuilder 内のFBModelCube型オブジェクトはそのまま残ります[4]。MotionBuilder 内のオブジェクトは Scene によって管理されており[5]、その管理オブジェクトと Scene 自体は Python の管理下に無いためです。

alt text
Wrapper が削除された場合。Scene 内のオブジェクトは残る


問題なのは『Wrapper は残っているが、Scene 内オブジェクト自体が削除された場合』です。cube.FBDelete()など、FBComponent自体を削除するメソッドを使用した場合に、しばしばこの状況が発生します。

この時、変数cubeは名前空間に残っているため Wrapper 自体にはアクセス可能ですが、その属性にアクセスしようとすると、エラーが発生します

alt text
Scene 内オブジェクトが削除された場合。オブジェクトへのポインタは無効になっている

# Scene 内のオブジェクトを削除
cube.FBDelete() 

# Wrapper 自体は取得できる
print(cube) 
# <types.FBModelCube_Unbound object at 0x0000023A7C5DCF90>

# 属性を使用しようとするとエラーになる
print(cube.Name)
# Traceback (most recent call last):
#   File "<MotionBuilder>", line 1, in <module>
#   File "C:\Program Files\Autodesk\MotionBuilder 2025\bin\config\Python\unbind.py", line 22, in UnboundWrapperGet
#     raise UnboundWrapperError()
# unbind.UnboundWrapperError: Python WrapperUnbound: SDK Object has been destroyed.


このエラーが UnboundWrapperError です。

直接的な原因は、Scene 内オブジェクトが削除されることで、Wrapper が保持していたポインタが無効になり、Wrapper の属性へのアクセス時のデータの参照先が失われたことにあります。前節で確認したように、Python SDK は内部の Open Reality SDK のオブジェクトに間接的にアクセスするため、ラッパークラスを介して内部データが取得できない場合は例外を送出することでその旨を通知する必要があります。

ところで、Wrapper が Open Reality SDK のオブジェクトへのポインタを保持していることを ”オブジェクトが接続されている(bound)”と表現するなら、そのオブジェクトへの参照を失った状態は unbound と呼べるでしょう。

つまり、内部データへの参照を失ったラッパークラスインスタンス(UnboundWrapper)について、その内部データを取得する属性へのアクセスを試みた際に送出する例外として、MotionBuilder が独自に用意したエラーが UnboundWrapperError なのです。

2.4. OnUnbind Event

Python SDK におけるFBPythonWrapperクラスには、Wrapper が Open Reality SDK オブジェクトへの参照を失う際に発生するイベント OnUnBind が実装されています。

alt text
Python Reference Guide - FBPythonWrapper より

このイベントに Callback を登録しておくと UnboundWrapper の発生を検知できるため、データを保持するようなツールを Python で開発する際に役立つ場合があります。

以下に簡単な使用例を示します。

from pyfbsdk import FBModelCube
cube = FBModelCube("Sample Cube")

# OnUnbind イベントに登録する Callback
def NotifyFunction(eventsource, event):
    print("The C++ object has just disconnected from its Wrapper.")
    print("  eventsource: ", eventsource)
    print("  event: ", event.Type)

# Callback を登録
cube.OnUnbind.Add(NotifyFunction)

上記実行後、cube.FBDelete()で Scene 内のオブジェクトを削除すると以下のような出力が確認できます。

cube.FBDelete()
# The C++ object has just disconnected from its Wrapper.
#   eventsource:  <pyfbsdk.FBModelCube object at 0x00000291C8A0C720>
#   event:  kFBEventUnbindSDK

eventsourceの表示に着目しましょう。SDK の各クラスのイベントに接続する Callback の第1引数には、イベントを発生させたオブジェクトが渡されるのですが、OnUnbindイベントに接続した Callback 内では、Wrapper はまだ有効な状態であると分かります。

ところで、FBDelete()後の UnboundWrapper 自体にprint()を使用した際の出力

print(cube)
#<types.FBModelCube_Unbound object at 0x00000149AEECD760>

から分かるように、オブジェクトの接続が解除された Wrapper は types.<ClassName>_Unbound 型へと変化します。Noneにはならないため、if節の条件式に「そのまま」置いてNoneかどうかを判定することはできない点に要注意です[6]

参考


3. エラーケースとその対策例

この章では、3つの UnboundWrapperError の発生ケースと、その対策例について紹介します。

3.1. 不十分な None 判定

3.1.1. 発生ケース

1つの Cube を扱うクラスを、以下のように実装したとします。

# よくない例!
from typing import Optional
from pyfbsdk import FBModelCube

class SingleCubeHandler:
    def __init__(self):
        self.__cube = None

    def get_cube(self) -> Optional[FBModelCube]:
        return self.__cube

    def set_cube(self, cube: FBModelCube) -> None:
        self.__cube = cube

    def delete_cube(self) -> None:
        if self.__cube:
            self.__cube.FBDelete()
            self.__cube = None

このSingleCubeHandlerクラスを使って、名称から Cube を登録・取得し、削除する以下のスクリプトを実行した場合は、特にエラーは発生しません。

handler = SingleCubeHandler()
cube = FBModelCube("MyCube")

handler.set_cube(cube)     # Cube を登録
print(handler.get_cube())  # 登録 Cube を取得
handler.delete_cube()      # 登録 Cube を削除

しかし、Cube 登録後delete_cube()を呼び出す前に Scene 内の Cube を削除してしまうと UnboundWrapperError が発生します。

cube.FBDelete()
handler.delete_cube()  # UnboundWrapperError 発生


cube.FBDelete()により Scene 内オブジェクトが削除されたことで、__cube属性は UnboundWrapper を参照するようになります。2.4節 で解説したように、UnboundWrapper は Noneとは判定されないため、delete_cube()においてif節内に処理が入った結果 UnboundWrapper の属性にアクセスしてエラーが発生したのです。

# よくない例!
def delete_cube(self) -> None:
    if self.__cube:             # UnboundWrapper の場合 True になる
        self.__cube.FBDelete()  # UnboundWrapper の属性にアクセス(エラー)
        self.__cube = None

3.1.2. 対策例

  • 型の確認

    組み込み関数type()を使います。今回は FBPlug.Is()は使えません(UnboundWrapper の場合、Is()属性にアクセスしてエラーになるので)。

    def delete_cube(self) -> None:
        if type(self.__cube) is FBModelCube:  # 型を確認
            self.__cube.FBDelete()
            self.__cube = None
    

    ちなみに、組み込み関数isinstance()UnboundWrapper と Wrap 元のクラスに対してTrueを返すので使えません

    # よくない例!
    def delete_cube(self) -> None:
        if isinstance(self.get_cube(), FBModelCube):  # True になる
            self.__cube.FBDelete()  # UnboundWrapper の属性にアクセス(エラー)
            self.__cube = None
    


  • 例外の捕捉

    MotionBuilder 側が例外設定をしてくれているのなら、try-except文で例外を捕捉するのも手です。UnboundWrapperError を捕捉する場合、unbindモジュールをインポートする必要があります。

    import unbind  # 必須
    
    def delete_cube(self) -> None:
        try:
            if self.__cube:
                self.__cube.FBDelete()  # UnboundWrapperError が発生する可能性
                self.__cube = None
        except unbind.UnboundWrapperError:
            print("UnboundWrapperError was raised.")
            self.__cube = None          # UnboundWrapper なら属性を None にする
        except Exception as e:
            FBMessageBox("Error", "[Error] " + str(e), "OK")  # その他の例外は通知
    


補足: データ削除時に属性を None に設定すること

SingleCubeHandler.delete_cube()の処理の最後にself.__cube = Noneとして属性をNoneに設定するのは適切で、if節の条件式を正常に動作させる助けになります。


3.2. Scene 切替時の破棄データへのアクセス

3.2.1. 発生ケース

Cube 作成後に Scene を新規作成する場合について考えましょう。以下のスクリプトを実行した場合、UnboundWrapperError が発生します。

# よくない例!
from pyfbsdk import FBApplication, FBModelCube
cube = FBModelCube("test cube")
FBApplication().FileNew()
cube.Show = True  # UnboundWrapperError 発生


FBApplication().FileNew()、つまり「File > New」を実行すると Scene は一新されるため、Cube を作成した旧 Scene のデータは既に破棄されています。上記スクリプトでは、FileNew()によりCubeが UnboundWrapper を参照するため、cube.Showでの Show属性へのアクセス時にエラーが発生します。

上記は非常に簡単な例ですが、実際のツール開発においてはデータクラスを実装し、Scene をまたいだ運用も必要な場合があるでしょう。Scene の切替(新規作成、新規ファイルの読み込み など)の対応を怠ると、動作テストの際にユーザーが続けて別ファイルを開いた瞬間にエラーが大量発生する、ということも十分起こり得ます。

3.2.2. 対策例

FBApplicationクラスのイベントを用いて、Scene 切り替えのタイミングを検知します。

重要なのは『どのタイミングを検出して、その際どんな処理をしたいのか』を明確にし、適切なイベントを選択することです。いずれのイベントも、その名称と実際の動作の対応に注意しましょう。
alt text
MotionBuilder Help - File Events より

自分がよく使用するのは OnFileNewCompleted イベントです。OnFileNewイベントが"Triggered when FBApplication.FileNew() is invoked,but before anything has been destroyed." なので、その completed なイベント、つまり『旧 Scene のデータが全て破棄された直後』を検出し、次の処理に備えて即座に内部データをリセットします。

from unbind import UnboundWrapperError  # この導入方法でも OK
from typing import Optional
from pyfbsdk import*
from pyfbsdk_additions import*

class SampleTool(FBTool):
    def __init__(self, name: str):
        super().__init__(name)
        self.__data = None  # ツールで使用するデータクラスのインスタンス等の保持
        self.populate_layout()
        self.OnUnbind.Add(self.on_unbind)  # ツール破棄時の処理
        self.app = FBApplication()

        # Scene 切替時のイベントに Callback を登録
        self.app.OnFileNewCompleted.Add(self.on_filenew_or_opened)

    # Scene 切替時に実行したい処理
    def on_filenew_or_opened(self, _eventsource, _event) -> None:
        print("A new scene has been created or opened.")
        self.__data = None  # ツールで使用するデータクラスのインスタンス等をリセット

    # ツールを破棄した際に、登録していた Callback を解除
    def on_unbind(self, _eventsource, _event) -> None:
        self.app.OnFileNewCompleted.Remove(self.on_filenew_or_opened)

    def create_cube(self, _eventsource, _event) -> None:
        if self.__data is None or self.__data.GetStatusFlag(FBPlugStatusFlag.kFBOwnedByUndo):
            self.__data = FBModelCube("Sample Cube")
            self.__data.Show = True
            self.__data.Scaling = FBVector3d(10, 10, 10)

    def print_cube_name(self, _eventsource, _event) -> Optional[str]:
        try:
            if self.__data:
                print(self.__data.Name)
            else:
                print("No Cube")
        except UnboundWrapperError:  # UnboundWrapperError の対策
            self.__data = None
            print("No Cube")

    def populate_layout(self) -> None:
        x = FBAddRegionParam(0,FBAttachType.kFBAttachLeft,"")
        y = FBAddRegionParam(0,FBAttachType.kFBAttachTop,"")
        w = FBAddRegionParam(0,FBAttachType.kFBAttachRight,"")
        h = FBAddRegionParam(0,FBAttachType.kFBAttachBottom,"")
        vbox = FBVBoxLayout()
        vbox_region_name = "vbox_region"
        self.AddRegion(vbox_region_name, vbox_region_name, x, y, w, h)
        self.SetControl(vbox_region_name, vbox)

        button1 = FBButton()
        button1.Caption = "Create Cube"
        button1.OnClick.Add(self.create_cube)
        vbox.Add(button1, 30, space = 10)

        button2 = FBButton()
        button2.Caption = "Print Cube Name"
        button2.OnClick.Add(self.print_cube_name)
        vbox.Add(button2, 30, space = 10)

tool_name = "Sample Tool"

if(FBToolList.get(tool_name) is None):
    tool = SampleTool(tool_name)
    FBAddTool(tool)

ShowToolByName(tool_name)


補足: OnFileOpenCompleted イベントの挙動について

名前からすると「File」>「Open」時に発生するイベントと思われますが、実際は「File」>「Merge」でも発生します(この節で示した、公式ヘルプの引用画像にも記載されています)。新しいデータが追加されたタイミングを検出する場合に有効ですが、それが Open なのか Merge なのかを判断する SDK の Property は無いので、フラグ等で独自に区別する必要があります。


3.3. Callback の登録解除忘れ

3.3.1. 発生ケース

Constraint を作成する度にUIのリストに追加し、ボタン押下時に削除するツールを考えます。

# よくない例!
from pyfbsdk import*
from pyfbsdk_additions import*

class SampleTool(FBTool):
    def __init__(self, name: str):
        super().__init__(name)
        self.__constraints = {}  # 監視 Constraint 一覧
        self.system = FBSystem()
        self.populate_layout()

        # Constraint 作成タイミングの検出のための Callback 登録
        self.system.OnConnectionNotify.Add(self.on_constraint_created)

    def on_unbind(self, _eventsource, _event) -> None:
        self.system.OnConnectionNotify.Remove(self.on_constraint_created)

    def on_constraint_created(self, eventsource: FBSystem, event: FBEventConnectionStateNotify) -> None:
        if(event.Action != FBConnectionAction.kFBConnected):
            return

        src_plug = event.SrcPlug
        dst_plug = event.DstPlug

        # Scene に接続された Constraint を検出してリストに追加
        if(isinstance(src_plug, FBConstraint) and type(dst_plug) is FBScene):
            self.ui_list.Items.append(src_plug.Name)
            self.__constraints[src_plug.Name] = src_plug

    def on_button_clicked(self, _eventsource, _event) -> None:
        for constraint_name, constraint in self.__constraints.items():
            try:
                if constraint is not None:
                    constraint.FBDelete()
                    self.ui_list.Items.remove(constraint_name)
                    print(f"Deleted constraint: {constraint_name}")
            except Exception:
                print(f"Error while deleting constraint {constraint_name}: {e}")

    def populate_layout(self) -> None:
        x = FBAddRegionParam(0,FBAttachType.kFBAttachLeft,"")
        y = FBAddRegionParam(0,FBAttachType.kFBAttachTop,"")
        w = FBAddRegionParam(0,FBAttachType.kFBAttachRight,"")
        h = FBAddRegionParam(0,FBAttachType.kFBAttachBottom,"")
        vbox = FBVBoxLayout()
        vbox_region_name = "vbox_region"
        self.AddRegion(vbox_region_name, vbox_region_name, x, y, w, h)
        self.SetControl(vbox_region_name, vbox)

        button = FBButton()
        button.Caption = "Delete Constraints"
        button.OnClick.Add(self.on_button_clicked)
        vbox.Add(button, 30, space = 10)

        self.ui_list = FBList()
        self.ui_list.Style = FBListStyle.kFBVerticalList
        vbox.Add(self.ui_list, 300, space = 10)

tool_name = "Sample Tool 2"

if(FBToolList.get(tool_name) is None):
    tool = SampleTool(tool_name)
    FBAddTool(tool)

ShowToolByName(tool_name)


上記スクリプトにより作成したツールを破棄した後、新たに Constraint を作成すると UnboundWrapperError が発生します。

FBDestroyToolByName(tool_name)          # ツールを破棄
FBConstraintRelation("New Constraint")  # Constraint を作成(エラー)


上記の良くない例では、on_unbind()メソッドがツールのOnUnbindイベントに登録されていません。この場合、ツールを破棄した後もFBSystem.OnConnectionNotifyイベントの際に Callback が呼び出されます。その際、既に破棄されたUI要素self.ui_listの属性にon_constraint_created()メソッドがアクセスしようとするため、UnboundWrapperError が発生します。

3.3.2. 対策例

このケースについては、「Callback を登録したら必ず解除する処理の実装も考えよう」としか言えません。ただし、その際に考慮できる基準が以下の2点あると思っています。

  • Event Source(イベントを発生させるオブジェクト)が System Object なら、ほぼ確定で解除処理を実装する
  • Event Source と(Callback をもつ)オブジェクトの生存期間がほぼ同じ なら、解除処理は不要な場合がある


System Object とはFBApplicationFBSystemなど、MotionBuilder アプリケーション全体で共有されるオブジェクトを指します。これらのオブジェクトはユーザーが削除できず、アプリケーション開始時から終了時まで存在し続けます。つまり、開発時に扱う殆どのクラスよりも生存期間が長く、Callback は一度登録したら解除されない限り延々と呼び出されることになるので、大抵の場合は解除処理を実装する必要があるでしょう。

class SampleTool(FBTool):
    def __init__(self, name: str):
        super().__init__(name)
        self.__constraints = {}
        self.system = FBSystem()
        self.populate_layout()

        # Constraint 作成タイミングの検出のための Callback 登録
        self.system.OnConnectionNotify.Add(self.on_constraint_created)

        # 登録 Callback の解除処理を忘れず実装する
        self.OnUnbind.Add(self.on_unbind)

    def on_unbind(self, _eventsource, _event) -> None:
        self.system.OnConnectionNotify.Remove(self.on_constraint_created)


一方で、例えばツールのUI要素(FBVisualComponent)はツールの破棄とほぼ同時に自動的に破棄されるため、UI要素のイベントに登録した Callback はツール破棄後は呼び出されることはありません。また、Button についてはユーザーが操作して押さない限りOnClickイベントが発生しません。この場合は、Callback の登録解除処理は不要でしょう。

# Button はツール破棄の際にほぼ同時に破棄される

def populate_layout(self) -> None:
    #(省略)

    button = FBButton()
    button.Caption = "Delete Constraints"
    button.OnClick.Add(self.on_button_clicked)  # 登録解除処理は不要
    vbox.Add(button, 30, space = 10)


4. 補足

4.1. Undo System 管理下のオブジェクト

UI操作でオブジェクトを消した場合(例: Deleteキー押下)、Scene からは削除されても Python 側で UnboundWrapper にならない場合があります。

from pyfbsdk import FBActor

actor = FBActor("test actor")
print(actor)
# <pyfbsdk.FBActor object at 0x000002A3F448C950>

# --- UI 上で Actor を削除 ---

# UnboundWrapper にならない
print(actor)
# <pyfbsdk.FBActor object at 0x000002A3F448C950>

# Scene からは消えている
print(len(FBSystem().Scene.Actors))
# 0


上記では、UI上で Actor を削除した場合も依然として型は保たれていますが、Scene から再取得することはできていません。これは、MotionBuilder の Undo System が操作の取り消しを可能にするため、オブジェクトを Scene 外に一時的に保持しているためです。

Undo System の管理下にあるか確認するには、FBPlug.GetStatusFlag()メソッドを使用し、kFBOwnedByUndoフラグが立っているか確認します。

from pyfbsdk import FBActor, FBPlug, FBPlugStatusFlag

def is_owned_by_undo(plug: FBPlug) -> bool:
    if plug is None:
        return False

    try:
        return plug.GetStatusFlag(FBPlugStatusFlag.kFBOwnedByUndo)
    
    except Exception:
        return False

actor = FBActor("test actor")
print(is_owned_by_undo(actor))
# False

# --- UI 上で Actor を削除 ---

print(is_owned_by_undo(actor))
# True


4.2. FBCharacterは UnboundWrapper にならない

理由は不明ですが、FBCharacterクラスはFBDelete()を使用しても UnboundWrapper になりません。

from pyfbsdk import FBCharacter
chara = FBCharacter("test")

# UnboundWrapper にならない
chara.FBDelete()
print(chara)
# <pyfbsdk.FBCharacter object at 0x00000238ED9EC630>


どうやら、FBDelete()後のFBCharacterオブジェクトには ObjectFlag が一切立たないようなので、FBComponent.GetObjectFlags()メソッドを使用し、立っているフラグが存在するかどうか確認します。

from pyfbsdk import FBCharacter, FBObjectFlag

def is_valid_character(comp: FBCharacter) -> bool:
    try:
        if comp is None:
            return False

        flags = comp.GetObjectFlags()
        for value in FBObjectFlag.names.values():
            if flags & value:
                return True

        return False

    except:
        return False

chara = FBCharacter("test")
print(is_valid_character(chara))
# True

chara.FBDelete()
print(is_valid_character(chara))
# False


5. おわりに

MotionBuilder インストールディレクトリ内のヘッダファイルを見れば、SDK の構造についてより理解を深められますが、インターネット上に出せない(公式が掲載していない)内容もあったため、この記事では多少ぼかして解説しました。そのため、実際に手元の環境で該当のファイルを確認することをお勧めします(特にFBScriptWrapper.h<ClassName>_Wrapperクラスなど)。

C++ と異なり、UnboundWrapper の属性にアクセスしたところでアクセス違反によるクラッシュが必ず起こる訳ではないので、無理に対策する必要は無いかもしれませんが、仕組みを理解して意図せず発生させないようにするべきです。エラーの発生原因と背景をおさえて『まず発生させない』を大事に、さらに『発生しても例外設定で捕捉する』を徹底しましょう。


最後までお読みいただき、ありがとうございました。


脚注
  1. もちろんその時点で参照カウントが0になれば、変数aが参照していたオブジェクトは破棄されます。また、print(a)で表示されるエラーはNameError: name 'a' is not defined であり、これは整数オブジェクトが見つからないのではなく、「a」という名前が名前空間内に見つからないことを示します。 ↩︎

  2. ただし、全ての機能が Python に公開されている訳ではなく、Python SDK で利用できる機能は Open Reality SDK の一部に限られます ↩︎

  3. 【MotionBuilder】Python SDK 入門 第1回 『導入』 - 1. MotionBuilder SDKについて ↩︎

  4. Scene がオブジェクトを管理し続けている限り、FBFindModelByLabelName()等のメソッドを用いれば、該当のオブジェクトは再取得できます ↩︎

  5. 本来、FBX SDK におけるオブジェクト作成は FbxScene を引数に取って行われ、結果として作成オブジェクトの Node が Scene の Node の階層構造に取り込まれます。あくまで推定ですが、MotionBuilder も内部に1つの FbxScene を保持し、同様のオブジェクト管理を行っているものと思われます ↩︎

  6. 個人的には C++ の Open Reality SDK でも、単純な nullptr チェックは極力避けるべきだと考えています。Open Reality SDK も、実際は SDK に公開されていないシステム内部のオブジェクトに対してある種の Wrap を行うような形で実装されており、SDK のオブジェクトが参照する内部オブジェクト(MotionBuilder が実際に直接処理するもの)が正常でない場合があるためです。内部データを含めオブジェクトが正常に生存していることを保証するため、HdlFBPlug::Ok()を用いて条件分岐を行うべきです。 ↩︎

Discussion