graphics.hatenablog.com

技術系テクニカルアーティストのあれこれ

Reading PyMEL: 02. createFunctions

前回の記事で "import pymel.core as pm" はなぜ遅いのか? を書いたので、ここをもうちょっと深堀りしてみる。
pm.polyCube() みたいに、PyMEL はなぜ MEL コマンドを直接呼ぶことができて、しかも返り値を PyNode で受けとれるのか?

PyMEL 経由でコマンドを叩くと何が起きるのか?

たとえばユーザーが pm.polyCube() を叩くと、PyMEL 内部では何が起きてるのか?……なんだけど、そもそも pm.polyCube ってなんだろう?

というわけで試してみた結果がこれ。

import pymel.core as pm
import pymel.core.modeling as pmm

print pm.polyCube  # -> <function polyCube at ...>
print pm.polyCube.__module__  # -> pymel.core.modeling
print pm.polyCube == pmm.polyCube  # -> True
print pm.polyCube.__code__  # -> <code object newFuncWithReturnFunc at ..., file "pymel\internal\factories.py", line 956>

ここから分かることとしては、、

  • pymel.core.polyCube は関数である。
  • pymel.core.polyCube の実体は pymel.core.modeling.polyCube である。
  • pymel.core.modeling.polyCube の実装は pymel/internal/factories.py で定義されている newFuncWithReturnFunc である。

pm.polyCube の実体は「pymel.core.modeling 内に定義された polyCube シンボルに、関数 newFuncWithReturnFunc を割り当てたもの」だった。ただし polyCube 以外にも大量のコマンドが pm 経由で呼べるようになっているわけで、ということは、ただ newFuncWithReturnFunc を直接呼んでいるわけではなく、newFuncWithReturnFunc に何らかの操作を与えて "PyNode を返す polyCube" に仕立て上げた高階関数 を呼んでいるのだろうと想像できる。同じように "PyNode を返す polySphere" や "PyNode を返す polyCone" もいるんだろう。

newFuncWithReturnFunc

newFuncWithReturnFunc は pymel/internal/factories.py 内の functionFactory で定義されている クロージャ で、ざっくりまとめるとこんなかんじになってる。

def functionFactory(funcNameOrObject, returnFunc=None, module=None, ...):
    inFunc = ...  # funcNameOrObject が文字列なら同名の関数を、funcNameOrObject が関数ならそのまま inFunc に代入
    if returnFunc:
        def newFuncWithReturnFunc(*args, **kwargs):
            res = inFunc(*args, **kwargs)
            res = returnFunc(res)
            return res
        newFunc = newFuncWithReturnFunc
    return newFunc

たとえば funcNameOrObject が 'test_func' という文字列なら、inFunc には "PyMEL 内のどこかに定義されてる test_func という関数" が代入されるイメージ。newFuncWithReturnFunc 自体は、inFunc と returnFunc を続けて実行するクロージャ を作成する関数、ということになる。

仮にここで、inFunc に cmds.polyCube、returnFunc に PyNode のコンストラクタが割り当てられていたらどうだろう?

polyCube = functionFactory(cmds.polyCube, pm.PyNode, ...)

上記のように functionFactory が呼び出された場合、以下のような処理が行われるイメージになる。

def functionFactory():
    def newFuncWithReturnFunc(*args, **kwargs):
        res = cmds.polyCube(*args, **kwargs)
        res = pm.PyNode(res)
        return res
    newFunc = newFuncWithReturnFunc
return newFunc

というわけで、これで cmds.polyCube の結果を PyNode 化して返す関数がつくれるなった。この関数を pymel.core.polyCube に代入してやればいい。同じようにして、maya.cmds 以下のすべてのコマンドを pymel.core モジュール内に取り込むことができる。

じゃあその、コマンドを pymel.core に取り込む 処理はどこで行われているのか?

createFunctions

上記「モジュールへのコマンド取り込み処理」を担当するのが、この記事のタイトルでもある createFunctions 関数になる。

短いから丸ごと引用する。moduleCmds とか nodeCommandList とか色々あるけど、大事なのは最初と最後。

def createFunctions(moduleName, returnFunc=None):
    module = sys.modules[moduleName]  # このモジュールにコマンドを取り込む
    moduleShortName = moduleName.split('.')[-1]
    for funcName in moduleCmds[moduleShortName]:
        if funcName in nodeCommandList:
            func = functionFactory(funcName, returnFunc=returnFunc, module=module)
        else:
            func = functionFactory(funcName, returnFunc=None, module=module)
        if func:
            func.__module__ = moduleName
            setattr(module, funcName, func)  # ここで module に func を追加

sys.modules から取得したモジュールオブジェクトに対して、functionFactory で作成した関数を直接 setattr してる。

で、あとはこれを PyMEL 内の各モジュールのグローバルスコープから呼んでやればいい。たとえば pymel/core/general.py の場合、一番最後の行 にこんな関数呼び出しが行われている。

_factories.createFunctions(__name__, PyNode)

このファイルのモジュールは pymel.core.general なので、第1引数の moduleName には 'pymel.core.general' が指定されることになり、第2引数の returnFunc は PyNode のコンストラクタが指定される。この呼び出しによって、moduleCmds['general'] 内で指定された関数が pymel.core.general に取り込まれる

というわけで次は、moduleCmds について更にソースコードを読み進める。

CmdCache

moduleCmds = None

def loadCmdCache():
    ...
    _cmdCacheInst = cmdcache.CmdCache()
    _cmdCacheInst.build()
    _setCmdCacheGlobals()

def _setCmdCacheGlobals():
    ...
    for name, val in zip(_cmdCacheInst.cacheNames(), _cmdCacheInst.contents()):
        globals()[name] = val

こういうロジックの組み方に対しては曲りなりにも技術者として思うところがないわけでもないが、さておき、CmdCache.cacheNames は ['cmdlist', 'nodeHierarchy', 'uiClassList', 'nodeCommandList', 'moduleCmds'] というリストを返す関数で、globals()['moduleCmds'] つまり moduleCmds の中身はここで設定される。

CmdCache の実装はそれなりに込み入っているのであえて触れない*1けど、まぁざっくり言って「pymel.core 内の各モジュール(general など)に maya.cmds 内のどのコマンドを取り込むか を管理している」というようなもの。たとえば polyCube は pymel.core.modeling モジュールに取り込まれているのだけど、このとき、{'modeling': ['polyCube', ...]} みたいな辞書型のデータを CmdCache が保持している。

さて、ここまでのソースコード全体では以下の機能が実現できている。

  • newFuncWithReturnFunc: maya.cmds 内の各コマンドの実行結果を PyNode 化して返す。
  • createFunctions: PyMEL 内の各モジュールに newFuncWithReturnFunc 化したコマンドを取り込む。
  • CmdCache: PyMEL 内のどのモジュールにどのコマンドを取り込むかを管理する。

あとは、上のほうでさらっと触れてスルーしてた "コマンド名" と "実際に maya.cmds 内に定義されている関数" との紐付け、つまり funcNameOrObject に相当する関数を探す 処理を特定すればこの話は終わり。

コマンド名と maya.cmds 内の関数との紐付け

def functionFactory(funcNameOrObject, returnFunc=None, module=None, ...):
    inFunc = ...  # funcNameOrObject に相当する関数を探す、funcNameOrObject が関数ならそのまま inFunc に代入
    ...

長くなるからコピペはしないけど、だいたいこんな処理。

1. funcNameOrObject が文字列で、引数 module が maya.cmds 以外なら、module 内から同じ名前の関数を探す。
2. funcNameOrObject が文字列で、引数 module が maya.cmds なら、pymel.internal.pmcmds モジュール内から同じ名前の関数を探す。
3. funcNameOrObject が関数なら、そのまま使う。
4. 上記のどれにも該当しなければ、何もしない。

1, 3, 4 に関しては何も難しいことはないので、特に気にしない。問題は 2 のケース。pmcmds というくらいだし、おそらく PyMEL 用に maya.cmds モジュール内の関数をあれこれするためのものなんだろう。

が、その前に「stubFunc」と「コマンドの引数と返り値」に触れておく必要がある。

MEL コマンドの実体

MEL や maya.cmds で提供されている関数群は、非常に多くの実装が DLL 形式で配布されている。つまり、ユーザーがコマンドを呼び出したとき、そのコマンドは Maya 自体が処理しているというよりは、"Maya 添付の DLL 内に定義された関数" を Maya が呼び出す ことで処理されていると解釈するのが正しい。

たとえば optionVar -q コマンドの実体は、どうやら ExtensionLayer.dll 内の readOptionVar という C++ 関数として実装されているらしい*2。つまり、optionVar -q というコマンドを実行できる状態にするためには、ExtensionLayer.dll の読み込みを済ませておかなくてはならない。*3

じゃあその DLL、一体いつロードされているのか?

DLL 遅延ロード

正解は、「そのコマンドが最初に実行される直前」だ。つまり、Maya 起動後にはじめて optionVar -q が実行される直前に、ExtensionLayer.dll が Maya に読み込まれる。

諸々の理由*4*5*6で Maya ユーザーがこれを意識する機会はほとんどない。が、ともあれそういう仕組みである以上は、コマンドを叩く前に "そのコマンド実装を含む DLL" が必ずロードされている状態を保証 しなくてはいけない。

そのために Maya が採用している設計が、stubFunc と呼ばれる仕組みだ。

stubFunc

Maya 本体には maya.app.commands という Python モジュールが組み込まれている。そこに _makeStubFunc というクロージャがあって、Maya のコマンドの多くはこれにラップされている。

def __makeStubFunc( command, library ):
    def stubFunc( *args, **keywords ):
        """ Dynamic library stub function """
        maya.cmds.dynamicLoad( library )
        # call the real function which has replaced us
        return maya.cmds.__dict__[command]( *args, **keywords )
    return stubFunc

__makeStubFunc の機能は実装を見てのとおり。コマンド実行直前に、DLL をロードするための dynamicLoad コマンドを実行しているだけだ。ただしこれにはちょっとした副作用があって、たとえば inspect モジュールなどで Python のコード解析を行ったときに、'stubFunc' というクロージャの名前 が解析結果の表面に出てきてしまう。これは厄介なことに cProfile の解析結果にも影響を及ぼしてしまい、プロファイリングの結果が "汚れ" てしまう。ふつうのユーザーには何の問題もないのだけど、それなりに込み入ったスクリプトを開発運用する必要のある(特に技術系の)TA にとっては厄介な問題になりうる*7

コマンドの引数と返り値の PyMEL-friendly にする

たとえば listRelatives について考えてみる。

maya.cmds.listRelatives の引数は当然ながら文字列型で渡す必要があって、たとえばこうする。

pCube1, _ = pm.polyCube()
print pm.listRelatives(pCube1.name())  # -> [u'|pCube5|pCubeShape5']

でもこれ、こうなってると嬉しい。*8

pCube1, _ = pm.polyCube()
print pm.listRelatives(pCube1)  # -> [nt.Mesh(u'pCubeShape5')]

コマンドの返り値を PyNode に変換する処理は newFuncWithReturnFunc でみた通り、既に実現できてる。でも、引数に直接 PyNode を渡す のは newFuncWithReturnFunc では実現できていない。これ以外にも、コマンドの中には FBXExportBakeComplexAnimation -v の返り値 'Success' みたいな、ロジックをプログラミングする上ではあまり意味のないメッセージを返してくるケースもあって、ぶっちゃけ「そんな文字列を返されるくらいならいっそ return None のほうがまだマシだろう」みたいに思えてくることもある。*9

これらを実現するために、PyMEL では getMelRepresentation という関数を用意して、PyNode から "MEL コマンドに渡すための文字列" を抜き出してくるようになってる。

wrappedCmd

この「stubFunc」と「コマンドの引数と返り値」の問題を解決するために、PyMEL では「"コマンド呼び出しをいいかんじにラップしたクロージャ" を newFuncWithReturnFunc にわたす」というアプローチをとっていて、これは pymel/internal/pmcmds.py の addWrappedCmd 関数内に実装されている。

addWrappedCmd の概要は以下のとおり。stubFunc に関しても、別の場所 で対応がされてる。

def addWrappedCmd(cmdname, ...):
    def wrappedCmd(*args, **kwargs):
        new_cmd = getattr(maya.cmds, cmdname)  # maya.cmds から本来のコマンド関数をとってくる

        # 引数に PyNode が含まれてたらいいかんじに文字列化する
        new_args = getMelRepresentation(args)
        new_kwargs = getMelRepresentation(kwargs)

        res = new_cmd(*new_args, **new_kwargs)  # maya.cmds 内のコマンド関数をラップする
        if res == '' and kwargs.get('edit', kwargs.get('e', False)):
            return None

    # ラップしていることを隠すために、本来のコマンドをラップするためのコードオブジェクトをつくる
    old_code = wrappedCmd.func_code
    new_code = types.CodeType(...)
    wrappedCmd = types.FunctionType(...)

    setattr(_thisModule, cmdname, wrappedCmd)  # ラップしたコマンド関数を PyMEL モジュールに追加する

というわけで、Maya 本体や各種プラグインに実装されてるコマンド群を、PyMEL 経由で自由に呼び出せるようになる。

*1:この記事を書くにあたって読んでみたけど、妙にややこしいわりに、あまり得るものがなかった……。

*2:dumpbin の解析結果からの推測なので、もしかしたら間違ってるかもしれない。。

*3:どのコマンドがどの DLL に含まれているかは、Maya インストールディレクトリ内の bin/commandList をテキストエディタで開けば確認できる。

*4:Maya の UI は基本的に MEL で実装されているので、UI や各種システム系コマンドを含む DLL は、結局 Maya 起動時に読み込まれている。

*5:Maya のシーンデータは MEL コマンドをシリアライズするかたちで構築されているので、シーン操作系の主たるコマンドを含む DLL は、だいたいシーンのロード時に読み込まれている。

*6:Maya 添付の DLL は数 MB 程度未満のサイズが大半なので、読み込みは一瞬で完了する。

*7:少なくとも PyMEL 開発者にとっては、厄介な問題だったようだ。

*8:もしかしたら嬉しくない人もいるかもしれないけど、とりあえず「嬉しい」ということにしておく。

*9:少なくとも PyMEL 開発者はそう考えた、らしい。