graphics.hatenablog.com

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

Reading PyMEL: 01. PyNode

ふと思い立ってコードを読んでみることにした。
github.com

まずは pymel.core.PyNode から。
意外と複雑だし、ちゃんと読んだことのある人ってそんなに多くはないんじゃなかろうか。

PyNode の機能

PyNode の機能は 2 つ、「シーン内のあらゆるオブジェクトへのアクセス」と「ノードの作成」。クラス名からして勘違いしてる人がいるかもしれないけど、実はこいつ、Py "Node" って名前のわりにノード以外の情報も扱うことができる。*1*2

PyNode.__new__() の docstring はこんなかんじ。ここには書いてないけど、PyNode('pCube1.vtx[*]') みたいなコードでコンポーネントにもアクセスできる。

""" Catch all creation for PyNode classes, creates correct class depending on type passed.


For nodes:
    MObject
    MObjectHandle
    MDagPath
    string/unicode

For attributes:
    MPlug
    MDagPath, MPlug
    string/unicode
"""

シーン内オブジェクトへのアクセス

さて、n = PyNode('pCube1') とかやっても PyNode 型のインスタンスが直接返ってくるわけではなく、引数で指定された名前のオブジェクトに応じた型のインスタンスを返してくれる。

cmds.file(new=True, force=True)
transform_name, shape_name = cmds.polyCube()

node = pm.PyNode(transform_name)
print node , type(node )  # -> pCube1 <class 'pymel.core.nodetypes.Transform'>

例えばこの場合、引数に指定された transform_name のノ
ードタイプは "transform" なので、変数 node には pymel.core.nodetypes.Transform 型のインスタンスが返されてくる。Transform は PyNode を継承したクラスで、Transform 以外にも PyMEL には Maya のノード体系に応じたノードタイプ型 が片っ端から定義されている。その中から、引数で渡された名前に応じた型を探して、そのインスタンスを作成する。つまり、スクリプトを書く側としては PyNode の機能を直接使うというよりも、Transform みたいに継承された型を通じてオブジェクトにアクセスすることがどうしても多くなってくる。

ちなみに実際に実装されてるノード用のクラス群はこんなかんじ。*3
f:id:hal1932:20190922013105p:plain

まぁそんなわけで、PyNode 自体で敢えて気にすることがあるとすれば、実際には PyNode インスタンスの作成処理くらいのものだ。

PyNode インスタンスの作成

Pythonインスタンスの作成といえば、まず思いつくのは __init__() だと思う。が、PyNode の場合は __init__() は空実装になってる。

def __init__(self, *args, **kwargs):
    # this  prevents the _api class which is the second base, from being automatically instantiated. This __init__ should
    # be overridden on subclasses of PyNode
    pass

理由は単純で、n = PyNode('pCube1') と書かれたときに PyNode 型ではなく 'pCube1' のノードタイプに応じた型(この場合は Transform 型)のインスタンスを返したい から。素直に __init__() を使うと、n には Transform ではなく PyNode 型のインスタンスが代入されてしまう。なので、PyNode では __init__() ではなく __new__() を使ってこれを解決している。

__init__ と __new__

Python のクラスシステムは __new__ が作成したインスタンスを __init__ が初期化する という仕組みになってる。詳しくは 公式ドキュメントこの記事 あたりを参照。PyMEL ではこれをメタクラス的に使っていて、__new__ の中で実際に生成すべきクラスのインスタンスの特定を行っている。

つまり、pCube1 という名前の transform ノードが存在するときに n = pm.PyNode('pCube1') を実行すると、PyNode.__new__ は 'pCube1' が transform ノードであることを特定して、PyNode 型ではなく Transform 型のインスタンスを返す。それを受け取った Python インタプリタは、PyNode ではなく Transform の作成が行われたと解釈して、Transform.__init__ が呼び出され、最終的に n には Transform 型のインスタンスが代入される。

これ系の仕組みを素直に実装するなら「"ノードタイプに応じた型のインスタンス" を生成する関数」を別途用意することが多そうだけど、Python の場合は __new__ で代替できるし、どの方法を採用するかはライブラリの設計思想次第なんだと思う。

あとは「そもそもこの仕組み必要?」っていうツッコミがあるかもだけど、ライブラリとしての使い勝手以上に、例えば cmds.ls なんかはノードタイプ関係ない上にコンポーネントなんかも普通に返してくるので、そのへんを透過的に扱おうと思ったらどうしてもこういう仕組みが必要になってくる。あと Maya は API のほうにも MObject という定義がいるので、MObject に相当する何かを PyMEL が実装していたとしても特に違和感はない。

作成すべきインスタンス型の特定

__new__ の引数に PyNode を継承したクラスが与えられたときは、PyNode 内にあらかじめ用意されている MPlug なり MDagPath なりをとってくる。それ以外の場合は、引数を文字列に変換して MSelectionList にいれてから、正しい値がとれるまで getPlug や getDagPath を try-catch しながら繰り返す。
MPlug や MDagPath を確保できたら、そこから MFnDependencyNode を取得して、typeName に該当する PyMEL 内のクラスを探す。

たとえば引数が文字列だった場合のタイプ判定処理は こんなかんじ

実際に PyMEL 内のクラスを探す処理については、pymel.core.general.py に _getPymelTypeFromObject という関数があって、ここで MFnDependencyNode.typeName の結果に応じた PyMEL 内型情報をとってきてる。具体的には nodetypes.mayaTypeNameToPymelTypeName という辞書型のグローバル変数内を検索することで型情報を検索している。

さて、実はこの辞書型変数の存在が、PyMEL の import が遅い原因*4のひとつになっていて、せっかくだからそのあたりもソースコードを追っていくことにする。

余談1: "import pymel.core as pm" はなぜ遅いのか?

この mayaTypeNameToPymelTypeName を辿っていくと、どうやら pymel/internal/factories.py の addPyNode 内で追加されてるらしいことが分かる。そしてこの addPyNode は pymel/core/nodetypes.py の _createPyNodes から呼び出される。_createPyNode は定義された直後にグローバルスコープで呼び出されているので、つまり import pymel.core.nodetypes した瞬間に実行される。

というわけで pymel/core/__init__.py をみてみると、当然ながら import されている、と。

# to allow lazy loading, we avoid import *
import nodetypes
import nodetypes as nt
import datatypes
import datatypes as dt
import uitypes
import uitypes as ui

ついでにもうひとつ、同じく mayaTypeNameToPymelTypeName を追っていくと、pymel/core/factories.py の MetaMayaNodeWrapper.__new__ でも書き換えられていることが分かる。MetaMayaNodeWrapper は PyMEL 内で定義されているすべてのノードタイプ型クラスのメタクラスとして指定されていて、各種ノードタイプ型クラスを PyMEL システムに登録したり、ノードタイプ型クラスとMEL コマンドとの間の繋ぎ込みを担当しているらしい。

上で挙げたとおり __new__ はそのクラスのインスタンスが作成されたときに呼ばれるのだけど、MetaMayaNodeWrapper はメタクラス用のクラスなので趣が変わってくる。 メタクラス用クラスのインスタンス化が行われるのは、そのメタクラスが使われているクラス定義が実行されたとき なので、各種ノードタイプ型クラスの定義が Python インタプリタによって処理されるタイミングで、MetaMayaNodeWrapper.__new__ は実行される。ということは、これもやっぱり import pymel.core.nodetypes の内部で実行されるわけだ。

というわけで、最後に "import pymel.core as pm" のプロファイリング結果を貼っておく。

ncalls  tottime  percall  cumtime  percall filename:lineno(function)
   1    0.066    0.066    9.990    9.990 pymel\core\__init__.py:1(<module>)
1984    0.022    0.000    7.560    0.004 pymel\internal\factories.py:3085(addPyNode)
1984    0.047    0.000    7.516    0.004 pymel\core\nodetypes.py:3870(__getattr__)
1984    0.227    0.000    7.466    0.004 pymel\core\nodetypes.py:3836(_unwrappedNodeTypes)
1985    0.033    0.000    7.235    0.004 pymel\internal\apicache.py:433(_getAllMayaTypes)
1985    0.818    0.000    7.202    0.004 pymel\internal\apicache.py:298(_getMayaTypes)
   1    0.000    0.000    6.168    6.168 pymel\core\__init__.py:270(_installCallbacks)
  48    0.002    0.000    6.162    0.128 pymel\core\__init__.py:113(_pluginLoaded)
  32    0.002    0.000    4.752    0.148 pymel\core\__init__.py:164(addPluginPyNodes)
 378    0.003    0.000    4.724    0.012 pymel\core\__init__.py:82(_addPluginNode)
 378    0.003    0.000    4.719    0.012 pymel\internal\factories.py:3047(addCustomPyNode)
3900    3.754    0.001    3.754    0.001 {built-in method nodeType}
   1    0.000    0.000    3.002    3.002 pymel\core\nodetypes.py:3(<module>)
   1    0.002    0.002    2.863    2.863 pymel\core\nodetypes.py:3915(_createPyNodes)
1985    1.728    0.001    1.728    0.001 {built-in method allNodeTypes}
 393    0.006    0.000    1.396    0.004 pymel\core\__init__.py:54(_addPluginCommand)
 393    0.016    0.000    1.250    0.003 pymel\internal\cmdcache.py:198(getCmdInfoBasic)

上の方で「PyMEL の import が遅い原因のひとつ」と書いたけど、まぁなんていうかだいたいこいつのせい。

余談2: ProxyUnicode

PyNode のクラス定義をみると、どうやら ProxyUnicode というクラスを継承しているらしい?

class PyNode(_util.ProxyUnicode):

ProxyUnicode の定義をみるとこんなことが書いてある。過去の PyMEL では PyNode を文字列としても扱いたいケースがあったんだろうか。ただまぁ、ふつうに考えてそんな設計は NG だろうし、今となっては開発者自らが「文字列として使うのはやめとけよ?」って言ってる。とはいえそんなコメントを PyMEL ユーザーがちゃんと読むわけがない。ProxyUnicode にはその対策も入ってる。

# Note - for backwards compatibility reasons, PyNodes still inherit from
# ProxyUnicode, even though we are now discouraging their use 'like strings',
# and ProxyUnicode itself has now had so many methods removed from it that
# it's no longer really a good proxy for unicode.

ProxyUnicode = proxyClass(unicode, 'ProxyUnicode', ...)

上記のコードを見てのとおり、ProxyUnicode は通常の class 構文ではなく proxyClass 関数によって生成されている。これ自体は Pythonnamedtuple みたいなもので、この場合は「第 1 引数の unicode 型を継承した ProxyUnicode という名前のクラス」を生成してる。ただ namedtuple と違って、継承したクラスのインターフェイスを増やしたり減らしたりできること。構文としては namedtuple に近い簡易的なものだけど、どちらかというとふつうのクラス継承にかなり近い。

特に重要なのはインターフェイスを減らすことで、たとえばこの場合は、unicode クラスで定義されている expandtabs や translate という関数が使えないようにしてある。PyMEL としては PyNode を文字列として使われたくないわけで、であれば、文字列型だったら使えたはずの関数を使えないようにしてしまうのは理にかなってる。だったら ProxyUnicode 自体を消してしまえばいいような気もするけど、まぁそのへんが歴史的経緯ってやつなんだろうなと。*5

ともあれ、現代の PyMEL を使う側としては、PyNode 型はあくまで PyNode 型であって、unicode 型とは何の関係もない……くらいの認識でいるのがよさそうだ。

さて、だいぶ長くなってしまったけど、「シーン内オブジェクトへのアクセス」についてはここまで。

ノードの作成

import pymel.core as pm
print nt.Transform()  # -> transform1

これについてはあまり書くことがない。。
各ノードクラス型で定義された __melnode__ の中身をそのまま cmds.createNode に渡して、返ってきたノード名を PyNode にして返してるだけ。つまり、createNode してること以外は「シーン内オブジェクトへのアクセス」とまったく同じだ。

class PyNode(_util.ProxyUnicode):
    def __new__(cls, *args, **kwargs):
        ...
        newNode = createNode(cls.__melnode__, **kwargs)
        if newNode:
            return cls(newNode)
        ...

いちおう過去には UI 系のクラス*6で __melcmd__ という関数が使われてたらしい形跡はあって、おそらく window とかのコマンドがこれを使って定義されてたような気がするんだけど、今となっては PyUI は PyNode を継承していないので特に使われている箇所もなく。まぁ忘れちゃってもいいと思う。

*1:というか、そもそもの話として pymel.core.nodetypes 内に定義されている Attribute や Component クラスは、PyNode を継承してる。なので、PyNode 自体はノードではなく「MObject 的な何か」だと捉えるのが正解に近い。

*2:じゃあ Node なんて名前付けんなよって話だが、PyMEL に限らずこの手の老舗ライブラリは往々にして様々な歴史的経緯を抱えているものだ。まぁ仕方なかろう。実際、PyMEL の黎明期には _BaseObj というクラスがあり、Attribute も Node も _BaseObj を継承していた。

*3:この画像は pymel.core に対する pyreverse の解析結果。余談だが、複雑なソースコード解析をするときはまずこんなかんじで全体を俯瞰してみるのがいい。

*4:そういえば 2018.6 の時点では PyMEL がデフォルトで import されないようになってた。普段 PyMEL はあまり使わないのでとてもうれしい

*5:isinstance(node, PyNode) ではなく isinstance(node, ProxyUnicode) とか書いちゃう輩が一定数いて収集がつかなくなったとか、そんなところな気がする。

*6:pymel.core.PyUI とか