cymelの紹介


メリークリスマス!
Maya Advent Calendar 2020 最後の記事は、一般技術の紹介じゃなくて恐縮ですが、拙作のオープンソース Python モジュール cymel の紹介です。

cymelって何?

cymel とは、皆さんご存知の pymel の置き換えになる、Maya の Python プログラミングを助けるモジュールです。
本家 pymel ほど高機能にはせず、謙虚で1、小さく軽く高速で、それでいてノードやアトリビュートの扱いを簡単にするという主目的には十分な機能を提供します。

詳しいことは以下のドキュメントに記載されていますが、この記事で興味を持ってもらえるように、ざっくり紹介していきたいと思います。
https://ryusas.github.io/cymel/ja/index.html

cymel の「c」は C++ の c です。コアは C++ で実装し、高速に動作させることを狙っています。
現在はフル Python で書かれたプレビュー版ですが、現段階でも非常に軽量です。
フル Python 版の完成後、今(2020年12月現在)は作業は一段落し C++ 版の開発を休んでいますが、そのうちきっとやります(笑)。

とりあえず使ってみる

まずは、以下の github リポジトリから cymel を入手してください。
https://github.com/ryusas/cymel

そして、その中の python フォルダを環境変数 PYTHONPATH に追加してください。
Maya 起動後に sys.path に加える形でも問題ありません。

import sys
sys.path.append(r'cymelのパス/python')

そしてインポート。
最も手っ取り早いのは以下のようにすること。

from cymel.all import *

これで cymel の全機能といくつかの定番のモジュールがおすすめの名前でインポートされ、わずかなおすすめ設定がされます。このとき何がされるのかはドキュメントの cymel.allのページ に記載されています。

もし、あなたが「import * やるの嫌いな人」だったり、「名前は好きにつける派」だったり、「ちょっとでも勝手な設定すんな」という場合は、以下のように cymel.main をインポートしてもOKです。

import maya.cmds as cmds
import cymel.main as cm

ちなみに cmds もインポートしているのは、cymel は pymel のようなコマンドのラッパーを提供しないからです。
なので、コマンドは標準の Maya のモジュールを使いましょう。

では、簡単な例として、キューブを1個作り、その位置を…なんでもいいですが、今日の日付 (2020, 12, 25) に設定してみます。

cmds.polyCube()
cm.sel.t.set((2020, 12, 25))
cm.sel.t.get()  # 確認

cm.sel は、Maya 上で選択されている最初のノードかプラグ(アトリビュート)を表します。
ちなみに、 cm.selection だと、選択されているもののリストになります。
また、pymel のように cm.selected 関数もありますが、sel や selection の方が手軽なのでおすすめです。

以下はもう少しだけ凝った例です。

from random import uniform as ru
import math
ms = math.log(5)
exp = math.exp
#PI = cm.PI  # cymel.all を import * していない場合に必要

a = cm.O(cmds.polyCube(n='a')[0])
b = cm.O(cmds.polyCube(n='b')[0])

a.t.set((ru(-5, 5), ru(-5, 5), ru(-5, 5)))
a.r.set((ru(-PI, PI), ru(-PI, PI), ru(-PI, PI)))
a.s.set((exp(ru(-ms, ms)), exp(ru(-ms, ms)), exp(ru(-ms, ms))))
a.sh.set((ru(-1, 1), ru(-1, 1), ru(-1, 1)))

b.setM(a.getM())

a と b という名で2つのキューブを作り、a の t (translate), r (rotate), s (scale), sh (shear) をランダムに設定します。そして、a からマトリックスを得て b にセットする(それによって b の t, r, s, sh がセットされる)ことで a と b を同じ状態にしています。

こんな感じで、なんとなく雰囲気はつかめましたかね。
では、続いて、基本から説明していきます。

CyObject

cymel には、全てのノードタイプに対応したクラスがあり、プラグインで追加されたクラスにも対応します。
また、ノードタイプに条件を追加したカスタムクラスを作ることもできます。

プラグ(アトリビュート)は Plug というクラスで扱われます。
Plug を継承してカスタムクラスを作ることもできます。

ノードもプラグも CyObject を基底クラスとしています。これは pymel の PyNode クラスに相当します。
ノード名やアトリビュート名などを CyObject クラスに渡すことで、適切なクラスのインスタンスが得られます。
CyObject はよく使うので O (アルファベット大文字のオー)という別名が定義されています。

cm.O('persp')
# Result: Transform('persp') # 

プラグ名の指定の感覚はMELと同じです。
たとえば、選択しているノード名を省略できます。
選択されているのがtransformノードの場合は、そのシェイプのアトリビュートにもアクセスできます。

cmds.polyCube()
# Result: [u'pCube1', u'polyCube1'] # 
cm.O('.tx')
# Result: Plug('pCube1.tx') # 
cm.O('.px')
# Result: Plug('pCubeShape1.pt[-1].px') # 
cm.O('.pt[3].px')
# Result: Plug('pCubeShape1.pt[3].px') # 

ノードオブジェクトからは、Pythonの属性のようにプラグを得ることができます。
それは実際は plug メソッドのショートカットです(pymel でいうと attr メソッド)。アトリビュート名がクラスのメソッド名などと衝突する場合は plug メソッドでアクセスします。

cm.sel.tx
# Result: Plug('pCube1.tx') # 
cm.sel.px
# Result: Plug('pCubeShape1.pt[-1].px') # 
cm.sel.pt[3].px
# Result: Plug('pCubeShape1.pt[3].px') # 
cm.sel.plug('pt[3].px')
# Result: Plug('pCubeShape1.pt[3].px') # 

ノードクラスは cm.nt からアクセスできます。
クラスメソッド ls で、シーン中のノードをリストアップするなんてこともできます。

cm.nt.Transform.ls()
# Result: [Transform('front'), Transform('persp'), Transform('side'), Transform('top')] # 
cm.nt.Transform.ls('*o*')
# Result: [Transform('front'), Transform('top')] # 
cm.nt.Time.ls()
# Result: [Time('time1')] # 

ノードクラスのコンストラクタの固定引数にノード名などを指定しない場合は「ノードの生成」となり createNode コマンドが呼び出されます(指定しているキーワード引数 n は createNode コマンドのオプションです)。

cm.nt.Transform(n='tmp#')
# Result: Transform('tmp1') # 
cm.nt.Mesh(n='tmp#')
# Result: Mesh('tmpShape2') # 

この辺りの仕組みは、pymel よりも細かいところで柔軟になっているかと思います。

ところで、pymel では全ての Maya コマンドのラッパーが提供されていて、引数に PyNode を直接指定したり、コマンドが返す結果も PyNode やそのリストで得ることができます。
しかし、cymel はコマンドラッパーを提供しません。そこまで必要ないだろうとの考えからです。

どういうことかといいますと…、
まず、全ての CyObject は文字列として評価できるので、CyObject やそのリストはほとんどのコマンドにそのまま渡すことができます。
また、コマンドが返すのは文字列やそのリストになりますが、それも必要に応じて cyObjects 関数(別名 Os が定義されています)で受けることで CyObject リストを得ることができます。
ノード名などを返すコマンドには、単一の文字列を返すものもあれば、文字列のリストを返すものがあったり、また、結果を返せない場合は空リストではなく None となるものもあったり…。まちまちですが、それらは全て Os で受けることができます。

cm.Os(cmds.polyCube())  # ノード名リストが返される。
# Result: [Transform('pCube1'), PolyCube('polyCube1')] # 
cm.Os(cmds.createNode('transform'))  # ノード名が返される。
# Result: [Transform('transform1')] # 
cm.Os(cmds.listRelatives(c=True, pa=True))  # 子ノード名リストが返されるが、子が無いと None となる。
# Result: [] # 

プラグ(アトリビュート)の扱い方

プラグの値のゲットやセットするには get メソッドや set メソッドを使用します。

このとき、distance や angle や time のいわゆる「単位付き型」の場合は、内部単位での扱いになることに注意してください。
つまり、UI上の設定に依存せず、常に、distance では centimeter、angle では radians、time では seconds で扱います。
その理由を説明すると長くなるので割愛しますが、通常、プログラミングでは UI 設定単位ではなく内部単位で扱うのがおすすめなのでこのような仕様になっています。

もし、どうしてもUI設定単位で扱いたい場合のために、 getusetu というメソッドも用意されています。
こちらは、プログラミングで使うというよりは、スクリプトエディター上でちょろっとコードをタイプして状況を確認するときなどに手軽に使うことを意図したものです。

cmds.polyCube()
# Result: [u'pCube1', u'polyCube1'] # 
cm.sel.r.set((PI, PI/2, PI/4))
cm.sel.r.get()
# Result: [3.141592653589793, 1.5707963267948966, 0.7853981633974483] # 
cm.sel.r.getu()
# Result: [180.0, 90.0, 45.0] # 
cm.sel.r.setu((10, 20, 30))
cm.sel.r.get()
# Result: [0.17453292519943295, 0.3490658503988659, 0.5235987755982988] # 

プラグの接続や切断は pymel のように >><<// 演算子で行うことができます。

a = cm.nt.Transform(n='a')
b = cm.nt.Transform(n='b')
a.t >> b.t  # b.t の入力に a.t を接続
a.t // b.t  # 切断
b.t << a.t  # a.t >> b.t と同じ
b.t.inputs()
# Result: [Plug('a.t')] # 

演算子ではなく connectdisconnect メソッドも使用できます。
ただし、 connect の指定順は pymel と逆なことに注意してください。

b.t.disconnect(a.t)  # a.t // b.t と同じ(pymelと同じ指定順)
b.t.connect(a.t)  # b.t << a.t と同じ(pymelと逆の指定順)
b.t.disconnect()  # ソースプラグの指定は省略できる

disconnect も connect も左側がデスティネーションと覚えると良いです。
接続は >> ではなく << を使用すると統一感があります。= による代入と同じ向きと考えるとしっくりきます。
ただ、// だと左側がソースという点は悩ましいので(さすがに、これも逆にすると、もっと混乱しそうなのでやめました)、これは使わず disconnect を使った方がいいかもしれません。その方がソース指定を省略できて便利ですし。

数学クラス

数学関連のクラスには、API に倣って以下のものが用意されています。カッコ内は別名です。

Vector と Matrix の例

ちょっと変わっているのは、API のように MPoint と MVector が無く Vector のみで両者を兼ねていることです。
これは、API での両者の使い分けが煩わしかったため、そのようにしました。
Vector は「API の MPoint のように同次座標表現に対応した3次元ベクトル」です。API MVector のように扱うために w=0 とする必要さえもなく、デフォルトの w=1 であれば w はほぼ隠されていて、MPoint のようにも MVector のようにも使うことができます。

以下は VectorMatrix の使用例です。

obj = cm.nt.Transform()
obj.r.setu([10, 20, 30])
obj.t.set([1, 2, 3])

m = cm.E(obj.r.get(), obj.ro.get()).asM()
m.setT(obj.t.get())
m.isEquivalent(obj.m.get())
# Result: True # 

v = cm.V(4, 5, 6)  # 省略した w は 1

v * m  # 位置ベクトル(MPoint相当)の変換
# Result: Vector(4.321477, 8.400376, 8.000298) # 
v.xform4(m)  # v * m と同じ
# Result: Vector(4.321477, 8.400376, 8.000298) # 
v.xform3(m)  # 方向ベクトル(MVector相当)の変換(translate無し)(w=1のままで可能)
# Result: Vector(3.321477, 6.400376, 5.000298) # 
cm.V(v.x, v.y, v.z, 0) * m  # w=0 を明示しても方向ベクトルの変換は可能だが、ほぼ必要ない
# Result: Vector(3.321477, 6.400376, 5.000298, 0.000000) # 

普通(デフォルト)は w=1 なので、普通に Matrix を掛けたり xform4 メソッドを使用すると、位置ベクトルの変換になります。
API の MVector と MMatrix の乗算のように、方向ベクトルとしての変換をしたい場合は xform3 メソッドを使用します。

「曲げ」と「ひねり」の分解・合成とか

cymel は pymel ほど高機能ではないといっても、細かいところは pymel 以上に結構凝っています。
たとえば、数学クラスには、リギング関連で便利なメソッドをそろえています。まあ、自分が使いたいからなんですが。

リガー御用達の 曲げとひねりの分解・合成 だって、 QuaternionasRollBendHV (asRHV) と makeRollBendHV (makeRHV) メソッドで一発でできちゃいます。
asRHV は、クォータニオンを [ひねり, 横曲げ, 縦曲げ] の3つの角度に分解します。
makeRHV は、分解された3つの角度を受け取りクォータニオンを合成します。

以下は使用例です。

q = cm.degrot(10, 20, 30).asQ()  # Degrees 指定で EulerRotation を作り Quaternion を得ている
rhv = q.asRHV()
rhv
# Result: [0.08010979786949313, 0.37275463597802466, 0.5069374349673035] # 
q1 = cm.Q.makeRHV(rhv)
q1.isEquivalent(q)
# Result: True # 

デフォルトだと、階層上位から見て「曲げてからひねる」順番の分解・合成ですが、オプション指定で「ひねってから曲げる」順番の分解・合成もできます。

rhv = q.asRHV(True)
rhv
# Result: [0.08010979786949314, 0.4114753076624365, 0.4769850031705235] # 
q1 = cm.Q.makeRHV(rhv, True)
q1.isEquivalent(q)
# Result: True # 

逆順にしても、ひねり角度は変わりませんが、曲げ角度に違いが現れます。
逆順で分解したものは、逆順で合成しないと元には戻りません。

Transformation の分解と合成

トランスフォーメーション情報を扱う Transformation クラスは、cymel ならではの面白い機能だと思います。

まず、簡単なところでは、トランスフォーメーション要素の分解や合成ができます。

では、試してみましょう。
scale, shear, rotate, translate を指定して適当なマトリックスを作ります。

m = cm.M.makeS([1.1, 1.2, 1.3])
m *= cm.M.makeSh([.1, .2, .3])
m *= cm.degrot(10, 20, 30, YXZ).asM()
m.setT([1, 2, 3])
m
# Result: Matrix(((0.862512, 0.573409, -0.370506, 0), (-0.496792, 1.086, 0.167959, 0), (0.502951, 0.506756, 1.18319, 0), (1, 2, 3, 1))) # 

Transformation にマトリックスを指定すれば、分解されたトランスフォーメーション要素を得ることができます。

x = cm.X(m, ro=YXZ)
x.s
# Result: ImmutableVector(1.100000, 1.200000, 1.300000) # 
x.sh
# Result: ImmutableVector(0.100000, 0.200000, 0.300000) # 
x.q
# Result: ImmutableQuaternion(0.0381346, 0.189308, 0.268536, 0.943714) # 
x.r
# Result: ImmutableEulerRotation(0.174533, 0.349066, 0.523599, YXZ) # 
x.r.asD()
# Result: [10.0, 19.999999999999993, 29.999999999999993] # 
x.t
# Result: ImmutableVector(1.000000, 2.000000, 3.000000) # 

Transformation を使って、トランスフォーメーション要素を合成したマトリックスを得ることもできます。

x = cm.X(t=[1, 2, 3], r=cm.degrot(10, 20, 30, YXZ), sh=[.1, .2, .3], s=[1.1, 1.2, 1.3])
x.m
# Result: ImmutableMatrix(((0.862512, 0.573409, -0.370506, 0), (-0.496792, 1.086, 0.167959, 0), (0.502951, 0.506756, 1.18319, 0), (1, 2, 3, 1))) # 
x.m.isEquivalent(m)
# Result: True # 

また、Transformation の持つ各属性値は読み書きが可能です。
各属性の型が Immutable... となっているのは、それらの一部要素だけ(scale の y だけとか)を書き換えられないという意味で、まるごと書き換える代入はできるわけです。
マトリックスや、クォータニオンや、各トランスフォーメーション要素値をセットすることで、それが影響する他の値を得ることができるようになっています。

なので、分解や合成の計算をする際、コンストラクタに入力値を全て指定しなければならないわけではなく、以下のように後から指定しても構いません。

x = cm.X()
x.m = m
x.ro = YXZ
x
# Result: Transformation(q=ImmutableQuaternion(0.0381346, 0.189308, 0.268536, 0.943714), s=ImmutableVector(1.100000, 1.200000, 1.300000), sh=ImmutableVector(0.100000, 0.200000, 0.300000), t=ImmutableVector(1.000000, 2.000000, 3.000000), ro=4) # 

x = cm.X()
x.t = [1, 2, 3]
x.r = cm.degrot(10, 20, 30, YXZ)
x.sh = [.1, .2, .3]
x.s = [1.1, 1.2, 1.3]
x.m
# Result: ImmutableMatrix(((0.862512, 0.573409, -0.370506, 0), (-0.496792, 1.086, 0.167959, 0), (0.502951, 0.506756, 1.18319, 0), (1, 2, 3, 1))) # 

また、とても紹介しきれませんが、ピボットや rotateAxis、jointOrient といった補助的な値を指定することも可能で、それらの条件のもとで scale, shear, rotate, translate を分解することができます。

各属性は、そっくりそのまま transform ノードのアトリビュート名に対応しています。
コード例ではショート名で指定していますが、もちろんロング名でも指定できます。

Transformation のセットとゲット

さらにさらに、 Transformation は、matrix型アトリビュートにセットやゲットができます。

その説明の前にちょっと前置き。
まず、transform ノードに標準で備わっている matrix 型アトリビュートについて、cymel で type を確認してみます。

obj = cm.nt.Transform()
obj.wm.type()  # worldMatrix
# Result: 'matrix' # 
obj.m.type()  # matrix
# Result: 'matrix' # 
obj.xm.type()  # xformMatrix
# Result: 'matrix' # 
obj.opm.type()  # offsetParentMatrix
# Result: 'at:matrix' # 

他にもありますが、とりあえず見てみた4個のうち、2020 で追加された offsetParentMatrix だけ、タイプが at:matrix となっています。
at: はタイプ名を区別するために cymel が付け足して表示しているものです。
matrix 型には addAttr コマンドでアトリビュートを追加するときに -dt で指定するか -at で指定するかの2種類があり、標準で備わっている matrix アトリビュートはほとんどが -dt なのですが、私の知る限り offsetParentMatrix だけが世にも珍しい -at となっています。

何故わざわざタイプの説明から入ったのかというと、-at でない方、つまり一般的によく使われる方の matrix 型は、値を Matrix 形式か Transformation 形式かの二通りで持つことができるからです。一方、-at の方は Matrix 形式の値しか持てません。

また、たとえ Transformation 形式で値が持たれていても Matrix として評価することはできます。
そして、MEL や pymel では Matrix 形式でしか値を得ることができません。
しかし、cymel の場合は、Transformation 形式で保存されているならば、きちんと Transformation 値として得ることができるのです。
以下のようになります。

obj.s.set([1.1, 1.2, 1.3])
obj.sh.set([.1, .2, .3])
obj.r.setu([10, 20, 30])
obj.ro.set(YXZ)
obj.t.set([1, 2, 3])

obj.wm.get()
# Result: Matrix(((0.862512, 0.573409, -0.370506, 0), (-0.496792, 1.086, 0.167959, 0), (0.502951, 0.506756, 1.18319, 0), (1, 2, 3, 1))) # 
obj.m.get()
# Result: Matrix(((0.862512, 0.573409, -0.370506, 0), (-0.496792, 1.086, 0.167959, 0), (0.502951, 0.506756, 1.18319, 0), (1, 2, 3, 1))) # 
obj.xm.get()
# Result: Transformation(s=ImmutableVector(1.100000, 1.200000, 1.300000), sh=ImmutableVector(0.100000, 0.200000, 0.300000), r=ImmutableEulerRotation(0.174533, 0.349066, 0.523599, YXZ), t=ImmutableVector(1.000000, 2.000000, 3.000000)) # 
obj.opm.get()
# Result: Matrix(((1, 0, 0, 0), (0, 1, 0, 0), (0, 0, 1, 0), (0, 0, 0, 1))) # 

最後の opm は、元々なにもセットしていないので単位行列なのは当たり前ですね。
そして、wm や m は Matrix なのに、xm だけ Transformation が得られています。
xm (xformMatrix) は transform ノードの持つトランスフォーメーション情報をそっくりそのまま得られるアトリビュートだからです。

Transformation が得られても、その matrix (m) 属性を評価すれば Matrix を得ることはできます。
しかし、プラグからゲットする際に getM メソッドを利用すれば、保存形式が Matrix と Transformation のどちらの場合でも常に Matrix で得ることができます。

obj.xm.get().m
# Result: ImmutableMatrix(((0.862512, 0.573409, -0.370506, 0), (-0.496792, 1.086, 0.167959, 0), (0.502951, 0.506756, 1.18319, 0), (1, 2, 3, 1))) # 
obj.xm.getM()
# Result: Matrix(((0.862512, 0.573409, -0.370506, 0), (-0.496792, 1.086, 0.167959, 0), (0.502951, 0.506756, 1.18319, 0), (1, 2, 3, 1))) # 

ところで、wm も m も xm も全てリードオンリーなアトリビュートなので、Matrix か Transformation かのどちらが得られるのかは、それぞれのアトリビュートの役割としてあらかじめ決まっているからに過ぎません。
でも、自分で作った書込み可能な matrix アトリビュートなら、その時々で好きな形式でセットすることができるし、ちゃんとその形式で得ることができます。

obj.addAttr('hoge', 'matrix', dv=obj.m.get())
obj.addAttr('piyo', 'matrix', dv=obj.xm.get())

obj.hoge.get()
# Result: Matrix(((0.862512, 0.573409, -0.370506, 0), (-0.496792, 1.086, 0.167959, 0), (0.502951, 0.506756, 1.18319, 0), (1, 2, 3, 1))) # 
obj.piyo.get()
# Result: Transformation(s=ImmutableVector(1.100000, 1.200000, 1.300000), sh=ImmutableVector(0.100000, 0.200000, 0.300000), r=ImmutableEulerRotation(0.174533, 0.349066, 0.523599, YXZ), t=ImmutableVector(1.000000, 2.000000, 3.000000)) # 

obj.hoge.set(cm.X())
obj.piyo.set(cm.M())
obj.hoge.get()
# Result: Transformation(s=ImmutableVector(1.000000, 1.000000, 1.000000), sh=ImmutableVector(0.000000, 0.000000, 0.000000), r=ImmutableEulerRotation(0, 0, 0, XYZ), t=ImmutableVector(0.000000, 0.000000, 0.000000)) # 
obj.piyo.get()
# Result: Matrix(((1, 0, 0, 0), (0, 1, 0, 0), (0, 0, 1, 0), (0, 0, 0, 1))) # 

obj.hoge.reset()
obj.piyo.reset()
obj.hoge.get()
# Result: Matrix(((0.862512, 0.573409, -0.370506, 0), (-0.496792, 1.086, 0.167959, 0), (0.502951, 0.506756, 1.18319, 0), (1, 2, 3, 1))) # 
obj.piyo.get()
# Result: Transformation(s=ImmutableVector(1.100000, 1.200000, 1.300000), sh=ImmutableVector(0.100000, 0.200000, 0.300000), r=ImmutableEulerRotation(0.174533, 0.349066, 0.523599, YXZ), t=ImmutableVector(1.000000, 2.000000, 3.000000)) # 

ちなみに、このコード例、さりげなく matrix 型アトリビュートのデフォルト値を指定しています。
実は、普通のコマンドや pymel だとそれはできないのですが、 cymel ならそれもできてしまいます。もちろん、アンドゥも問題ありません。

Transformation 形式で値を保持しておくメリットは、マトリックスを合成する前の細かなアトリビュート値の状態をそっくりそのまま保存できることです。
skinCluster のバインドポーズなどの保持に使われている dagPose ノードには、ノードのローカルマトリックスが Transformation 形式で保存されています。だから、Go to Bind Pose とかやると、単にポーズが元に戻るだけでなく jointOrient などの補助的なアトリビュート値もそっくり元の状態に戻せるのです。

cymel では、そのような操作も簡単です。
たとえば、以下は、先ほどのコード例でアトリビュートに保存してあった Transformation 値を、別のノードにそっくりセットする例です。

dst = cm.nt.Transform()
dst.setX(obj.piyo.get())

こうすると、scale、shear、rotate、translate だけでなく、ピボットなどの様々な情報もまとめてセットされるわけです。

とても紹介しきれない…orz

こうやって書いていくと、紹介したいネタはたくさんあって、とても書ききれません。ていうか、アドベントカレンダーに間に合わなくなっちゃうので、今回はこの辺で締めようかと思います。

最後に pymel との速度比較など。

以下のコードは、アトリビュート値のゲットとセットの操作の速度比較です。
pymel の遅さが問題になるのはコンポーネントの操作などで、この程度の操作なら問題はありませんが、まあ一応比較してみましょう。

import maya.cmds as cmds
from cymel.all import *
import pymel.core as pm
import timeit
from random import uniform as ru
import math
ms = math.log(5)
exp = math.exp

cmds.file(f=True, new=True)
for i in range(1000):
    cm.nt.Transform()
    cm.sel.s.set((exp(ru(-ms, ms)), exp(ru(-ms, ms)), exp(ru(-ms, ms))))
cmds.select('transform*')

def by_pymel():
    for obj in pm.selected(type='transform'):
        obj.s.set(obj.s.get() * 2)

def by_cymel():
    for obj in cm.nt.Transform.ls(sl=True):
        obj.s.set([x * 2 for x in obj.s.get()])

with cm.UndoChunk():
    print(timeit.timeit(by_pymel, number=1))
cmds.undo()
print(timeit.timeit(by_cymel, number=1))

まず、transform ノード1000個を作ってランダムな scale をセットし、全てを選択しています。
そして、「選択している全ノードの scale をゲットして2倍してセットする」という操作を pymel と cymel でそれぞれ実行し、timeit モジュールで速度を測っています。

Maya 2020 で、私の環境では、pymel だと 0.48秒くらい、cymel だと 0.13秒くらいでした。
実行するたびに変動はしますが、だいたい一貫して cymel は pymel の 1/3 以下の時間で処理できていました。

そして、cymelは、まだフルPython実装のプレビュー版です。
C++ で実装したときには、もうちょい速くなるといいなと思っています。

2021.2.7: 追記~さらに比較

最初に記事を投稿したあと、「pymelと速度比較とかしちゃったけど、cmdsとも比較しないとフェアじゃないよなあ」なんて思いました。
「どうせcmdsが最強だと思うけど…、もしかするともしかするかも?」なんて期待もちょっとあったりして。

さらに、この記事を公開したとき、株式会社TAの上原さんが「cmdxから乗り換えるぞお」とツイートされていたので、「cmdxって何よ」と気になり、 cmdx を調べたら…、なにやらめちゃくちゃ速いらしい?
気になるので比較してみましょう。
あとついでに彼の metan も引き合いに出してみるかと(ごめんなさい)。
ちなみに、cmdxのページで引き合いに出されている MRV も比較したかったのですが、Maya2020で動かないし、ずっと更新が止まっているみたいなのでやめました。

import maya.cmds as cmds
import cymel.main as cm
import pymel.core as pm
import timeit
from random import uniform as ru
import math
ms = math.log(5)
exp = math.exp

cmds.file(f=True, new=True)
for i in range(1000):
    cm.nt.Transform()
    cm.sel.s.set((exp(ru(-ms, ms)), exp(ru(-ms, ms)), exp(ru(-ms, ms))))
cmds.select('transform*')

def dotest(f):
    with cm.UndoChunk():
        print('%s=%f' % (f.__name__, timeit.timeit(f, number=1)))
    if f.__name__ != 'by_cmdx':
        cmds.undo()

def by_pymel():
    for obj in pm.selected(type='transform'):
        obj.s.set(obj.s.get() * 2)
dotest(by_pymel)

def by_cymel():
    for obj in cm.nt.Transform.ls(sl=True):
        obj.s.set([x * 2 for x in obj.s.get()])
dotest(by_cymel)

def by_cmds():
    for obj in cmds.ls(sl=True, type='transform'):
        cmds.setAttr(obj + '.s', *[x * 2 for x in cmds.getAttr(obj + '.s')[0]])
dotest(by_cmds)

try:
    import metan.core as mtn
except:
    pass
else:
    def by_metan():
        for obj in mtn.selected(type='transform'):
            obj.s.set([x * 2 for x in obj.s.get()])
    dotest(by_metan)

try:
    import cmdx
except:
    pass
else:
    def by_cmdx():
        for obj in cmdx.ls(sl=True, type='transform'):
            cmdx.setAttr(obj + '.s', [x * 2 for x in cmdx.getAttr(obj + '.s')])
    dotest(by_cmdx)

Maya2020起動したてで実行したら、以下の結果でした。

by_pymel=0.429272
# Undo:  # 
by_cymel=0.119997
# Undo:  # 
by_cmds=0.048561
# Undo:  # 
by_metan=0.144782
# Undo:  # 
by_cmdx=0.085275

速い順に
cmds > cmdx > cymel > metan >> pymel
という感じです。

思ったとおり、このテストでは cmds が最強です!
次点の cmdx が期待したほど速くないのは…、私の使い方が良くない可能性もありますが、速くなるのは、同じノードへの操作が続くような場合に限られるのではないかと想像しています。
metan はなかなか頑張っています。

cmdx は、ちょっとかじった限り、独特で、pymel や cymel や metan のような使い勝手を狙ったものではないように見えるので、比較対象としてはあまり良くないかもしれません。
アンドゥできないのもいただけません(そのため、上記コードでは最後に実行しています)。

ただ、このテストは、実は最初からフェアな比較を狙っていて、cymel などのラッパーがあまり有利でないと思う処理にしています。それは 1000個の「別のノード」に対する処理の合計時間の比較だからです。同一ノードへの処理を1000回繰り返す場合は、ラッパーはもう少し有利になっていくのではないかと思います。
あ、ここまで書いてたら比較したくなりました(最初はこの先の比較をする気なかったのですが)。
ちなみに、同じノードのscaleを2倍にし続けると、栗まんじゅう になってしまうので、2倍と0.5倍を繰り返すようにしました。

import maya.cmds as cmds
import cymel.main as cm
import pymel.core as pm
import timeit
from random import uniform as ru
import math
ms = math.log(5)
exp = math.exp

cmds.file(f=True, new=True)
cm.nt.Transform()

scales = [.5 if x % 2 else 2. for x in range(1000)]

def dotest(f):
    with cm.UndoChunk():
        print('%s=%f' % (f.__name__, timeit.timeit(f, number=1)))
    if f.__name__ != 'by_cmdx':
        cmds.undo()

def by_pymel():
    obj = pm.selected(type='transform')[0]
    for s in scales:
        obj.s.set(obj.s.get() * s)
dotest(by_pymel)

def by_cymel():
    obj = cm.sel
    for s in scales:
        obj.s.set([x * s for x in obj.s.get()])
dotest(by_cymel)

def by_cmds():
    obj = cmds.ls(sl=True, type='transform')[0]
    for s in scales:
        cmds.setAttr(obj + '.s', *[x * s for x in cmds.getAttr(obj + '.s')[0]])
dotest(by_cmds)

try:
    import metan.core as mtn
except:
    pass
else:
    def by_metan():
        obj = mtn.selected(type='transform')[0]
        for s in scales:
            obj.s.set([x * s for x in obj.s.get()])
    dotest(by_metan)

try:
    import cmdx
except:
    pass
else:
    def by_cmdx():
        obj = cmdx.ls(sl=True, type='transform')[0]
        for s in scales:
            cmdx.setAttr(obj + '.s', [x * s for x in cmdx.getAttr(obj + '.s')])
    dotest(by_cmdx)
by_pymel=0.375697
# Undo:  # 
by_cymel=0.064846
# Undo:  # 
by_cmds=0.044542
# Undo:  # 
by_metan=0.065137
# Undo:  # 
by_cmdx=0.061938

速い順に
cmds > cmdx > cymel > metan >>> pymel
となりました(順序は変わらず)。
pymel は遅いですが、cmdx と cymel と metan はだいぶ cmds に迫ってきました。3者の速度差は大差はない感じで、順序が入れ替わることもありました。

ただ、上記のテストでは、ノードは1つに固定したものの、プラグ(アトリビュート)は毎回指定し直しています。それも固定するとどうなるでしょうか。

import maya.cmds as cmds
import cymel.main as cm
import pymel.core as pm
import timeit
from random import uniform as ru
import math
ms = math.log(5)
exp = math.exp

cmds.file(f=True, new=True)
cm.nt.Transform()

scales = [.5 if x % 2 else 2. for x in range(1000)]

def dotest(f):
    with cm.UndoChunk():
        print('%s=%f' % (f.__name__, timeit.timeit(f, number=1)))
    if f.__name__ != 'by_cmdx':
        cmds.undo()

def by_pymel():
    plug = pm.selected(type='transform')[0].s
    for s in scales:
        plug.set(plug.get() * s)
dotest(by_pymel)

def by_cymel():
    plug = cm.sel.s
    for s in scales:
        plug.set([x * s for x in plug.get()])
dotest(by_cymel)

def by_cmds():
    plug = cmds.ls(sl=True, type='transform')[0] + '.s'
    for s in scales:
        cmds.setAttr(plug, *[x * s for x in cmds.getAttr(plug)[0]])
dotest(by_cmds)

try:
    import metan.core as mtn
except:
    pass
else:
    def by_metan():
        plug = mtn.selected(type='transform')[0].s
        for s in scales:
            plug.set([x * s for x in plug.get()])
    dotest(by_metan)

try:
    import cmdx
except:
    pass
else:
    def by_cmdx():
        plug = cmdx.ls(sl=True, type='transform')[0] + '.s'
        for s in scales:
            cmdx.setAttr(plug, [x * s for x in cmdx.getAttr(plug)])
    dotest(by_cmdx)
by_pymel=0.263930
# Undo:  # 
by_cymel=0.048010
# Undo:  # 
by_cmds=0.043140
# Undo:  # 
by_metan=0.061901
# Undo:  # 
by_cmdx=0.054413

速い順に
cmds > cymel > cmdx > metan >>> pymel
となりました。
cmds の優位は揺るぎませんが cymel がかなり肉薄しています。

cymel はノードから一度取得した Plug をノードにキャッシュするので、1つ前のコードでも速いと思っていたのですが、それでも差がでるものですね…。
ちなみに、現在の cymel の Plug.set メソッドは、内部で cmds.setAttr を呼び出しているので cmds より速くなることは誤差以外には絶対にありません。

ここまでテストして cmds が最強というのは、教訓として非常に大事なことです。肝に銘じておく必要があります。
ただ、pymel はとても遅いということ、cymel は現時点でもそうでもないということは証明できたかと思います。
そして、cymel コアを C++ 実装にしたときには、最初のテストコードでもコマンドに肉薄するくらいの性能を出せたら良いと願っています。最初のコードは、同じノードのアトリビュートをゲットしてセットしているので、同じノードに2回アクセスしているため、このケースではコマンドよりも速くなる可能性もあるかもしれません。

おわりに

いかがでしたでしょうか。
cymel にちょっとでも興味を持って頂けたなら幸いです。

cymel は pymel のパクリなので、いろいろと改善されているのは当たり前ですね。
それに、私の本業では、結構昔から別の「pymel モドキ」を開発して使っています(そのことは過去の講演で発表しているので…)。
でもそれは個人用途では使えず不便だったので、オープンソースで全く新しいものとして作ったのが cymel です。
元々 pymel から多くのことを学びましたし、開発経験もあったわけなので、改善できるのは当たり前ですよね。

とにかく、普通はあまり気づきにくいような細かいところにも非常にこだわって作っています。

ちなみに、本業では cymel はまだ使っていないのですが、試したら、やっぱり便利だなということもありました。
たとえば、コンパウンドのマルチアトリビュートの子にもマルチアトリビュートがある場合。上位のマルチエレメントを削除してアンドゥすると、cmds でも pymel でも、元祖 pymel モドキでも、下位のマルチエレメントは元に戻りません。でも cymel なら問題なく元に戻ります。
かなり細かい話ですけどね。。そういう細かい信頼性の高さみたいなところが色々あると思いますよ。まだバグもあるかもしれないけれど。。

cymel には「コアを C++ で実装し直す」「将来 Maya が Python 3 対応したら追従する」という大きな仕事が残っていますが、現状でも既に結構使い物になるレベルだとは思いますので、よろしければ使ってみてください。バグがあったら直しますー。

それでは、皆様、良い年末年始をお過ごしください。
今年は世界が一変してしまったような年でしたが、来年は幸多い年だといいですね!


  1. 標準APIクラスを書き換えたり、rootロガーレベルを変更したり、MMessageコールバックを沢山埋め込んだり等の傍若無人な振る舞いをしません。