Maya OpenMaya API入門|API 2.0でメッシュ処理を高速化

Maya OpenMaya API入門|API 2.0でメッシュ処理を高速化

Pythonで横断パイプライン|Maya/Houdini/Painterを繋ぐ設計では、Maya の Python API を cmds・pymel・OpenMaya の3層に分け、「普段は cmds、重い処理だけ OpenMaya に降ろす」という使い分けを紹介しました。本記事はその続編として、OpenMaya の Python API 2.0(モジュール名 maya.api.OpenMaya)を実際に書く手順を解説します。名前からノードを取り出す MSelectionList、メッシュを読み書きする MFnMesh、要素を順にたどるイテレータ、cmds との速度差の測り方、そして OpenMaya でシーンを書き換えるときに問題になるアンドゥの扱いまでを、コード付きで順に見ていきます。

クラス名・メソッド名・引数は、執筆時点で最新の Maya 2027(同梱の Python は 3.13)の公式リファレンスで確認しています。メニュー名などの表記はバージョンによって異なります。パイプライン全体の設計は親記事に任せ、ここでは Maya の中での書き方に絞ります。Houdini 側の同じ位置づけの記事はPython in Houdini入門|houモジュールでノード操作を自動化です。

夕宮たいだ

ふぁ……みんな〜、今日は Maya の奥のほう、OpenMaya のおはなしだよぉ。cmds だと重たい処理を軽くするための道具なんだぁ。コード多めだけど、一緒にいこ〜。

目次

OpenMaya(Python API 2.0)の立ち位置

ひとことで:cmds が「コマンドを名前で呼ぶ窓口」なら、OpenMaya は「シーンのデータに直接さわる窓口」です。

cmds は MEL のコマンドを Python から呼ぶ仕組みで、ノードは毎回名前(文字列)で指定します。OpenMaya は Maya の C++ API を Python から使えるようにしたもので、ノードを MObject や MDagPath というオブジェクトで受け取り、メッシュの頂点配列などを直接読み書きできます。

Python から使える OpenMaya には、古い API 1.0(maya.OpenMaya)と新しい API 2.0(maya.api.OpenMaya)の2系統があります。Python API 2.0 Reference の概要ページでは、2.0 の利点として次の点が挙げられています。

  • 配列クラスが Python のシーケンスとして扱え、スライスも使える
  • 結果を引数ではなく戻り値で返すので、1.0 で必要だった MScriptUtil が要らない
  • 失敗したときの例外が RuntimeError だけでなく、内容に応じた種類に分かれている
  • 全体に 1.0 より速く、処理によっては最大で3倍ほど速い

新しく書くコードは 2.0 にそろえます。1.0 と 2.0 は同じスクリプトの中で両方 import できますが、互いのオブジェクトは渡し合えません。1.0 の MObject を 2.0 のメソッドに渡すとエラーになるので、混ぜないのが一番安全です。

本記事のコードは、Script Editor(Windows > General Editors > Script Editor)の Python タブで実行する前提です。最初に次の2行を実行しておきます。

import maya.api.OpenMaya as om
import maya.cmds as cmds
cmds と OpenMaya のデータの受け取り方の違い
図1:cmds は名前で1つずつ、OpenMaya は経路から配列でまとめて受け取る

MSelectionList で名前から MDagPath を取り出す

ひとことで:OpenMaya の入口は「名前 → MSelectionList → MDagPath」という変換です。

MFnMesh などの関数セット(ノードを操作するためのクラス)は、名前ではなく MObject か MDagPath を受け取ります。名前から取り出すときは、MSelectionList に名前を追加してから取り出します。

MObject と MDagPath の違い

MObject はノードそのものを指すハンドルです。MDagPath は、DAG(ノードの親子階層)の上から、どの親をたどってそのノードに至るかという経路です。インスタンスで1つのシェイプを複数の親の下に置くと、1つのノードに経路が複数できます。そのため MObject だけでは「どの位置にあるそれか」が決まらず、公式リファレンスでもワールド空間の計算はできないと説明されています。DAG ノードは MDagPath で受け取る、と覚えておけば困りません。

インスタンスのシェイプを指す MObject と2本の MDagPath の図
図2:同じシェイプでも経路が2つあれば位置も2つ。ワールド座標は MDagPath で扱う
夕宮たいだ

MObject と MDagPath……むずかしいねぇ。でも「DAG のノードは経路ごと持つ」って覚えとけば、だいたい大丈夫なんだぁ。

名前からメッシュの MDagPath を得る関数

次の2つの関数は、トランスフォーム名(pCube1)とシェイプ名(pCubeShape1)のどちらを渡されても、メッシュシェイプの MDagPath を返します。デフォーマーを使ったメッシュには、変形前の形を持つ中間オブジェクト(Intermediate Object)のシェイプが同じトランスフォームの下にあるので、それを除外しています。

def to_mesh_path(path):
    """トランスフォームなら、直下の表示用メッシュシェイプまで経路を伸ばす"""
    if path.hasFn(om.MFn.kMesh):
        return path
    for i in range(path.numberOfShapesDirectlyBelow()):
        shape = om.MDagPath(path)          # 元の経路を残すため、複製してから伸ばす
        shape.extendToShape(i)
        if shape.hasFn(om.MFn.kMesh) and not om.MFnDagNode(shape).isIntermediateObject:
            return shape
    return None

def get_mesh_path(name):
    """名前からメッシュシェイプの MDagPath を返す"""
    if not cmds.objExists(name):
        raise ValueError(f"{name} が見つかりません")
    sel = om.MSelectionList()
    sel.add(name)
    path = to_mesh_path(sel.getDagPath(0))
    if path is None:
        raise ValueError(f"{name} の下にメッシュがありません")
    return path

getDagPath() は、指定した番号の項目が DAG ノードでないとき TypeError を出します。いま選択しているものを使いたいときは、om.MGlobal.getActiveSelectionList() が MSelectionList を返すので、同じように getDagPath() で取り出せます。以降のコードは、この2つの関数を定義した状態で進めます。

MFnMesh で頂点・法線・UV を読む

ひとことで:MFnMesh に MDagPath を渡せば、メッシュの情報を配列ごとまとめて取り出せます。

MFnMesh はメッシュ用の関数セットです。頂点数などの件数はメソッドではなくプロパティになっていて、() を付けずに読みます。

cube = cmds.polyCube()[0]                        # 動作確認用の立方体
fn = om.MFnMesh(get_mesh_path(cube))

print(fn.numVertices, fn.numPolygons)            # 8 6
points = fn.getPoints(om.MSpace.kWorld)          # 全頂点の座標(MPointArray)
normals = fn.getVertexNormals(False, om.MSpace.kWorld)
us, vs = fn.getUVs()                             # 現在の UV セットの U と V
print(points[0], normals[0], us[0], vs[0])
print(fn.getUVSetNames())                        # UV セット名の一覧
  • getPoints() は頂点座標のコピーを MPointArray で返します。om.MSpace.kWorld でワールド座標、省略すると om.MSpace.kObject(オブジェクト座標)です
  • getVertexNormals() は頂点ごとの法線を返します。1つ目の引数は面の角度で重み付けするかどうかで、False のほうが計算は速くなります。面ごとに分かれている法線は、頂点単位に平均した値になります
  • getUVs() は U の配列と V の配列の組を返します。UV セット名を渡さなければ、現在の UV セットが使われます

ハードエッジと頂点法線の関係はMayaのスムージングと頂点法線で詳しく扱っています。

配列は要素を「参照」で返す

MPointArray のように、クラスの要素を持つ配列は、取り出した要素が複製ではなく配列の中身そのものを指します。公式リファレンスの配列クラスの解説(Working with M*Array Classes)では、これを参照セマンティクスと呼んでいます。次のように書くだけで、配列の中身が書き換わります。

moved = om.MPointArray(points)   # 先に複製を作る(元の points はそのまま)
for p in moved:
    p.y += 1.0                   # moved の中身が直接変わる

コピーの手間がないぶん速い反面、要素を変数に持ったまま元の配列を空にしたり作り直したりすると、存在しない要素を触って Maya が不安定になると公式に注意書きがあります。要素を持ち続けている間は、その配列を作り直さないようにします。

イテレータでシーンとメッシュを順にたどる

ひとことで:MItDag でシーン内のノードを、MItMeshPolygon などでメッシュの要素を1つずつたどります。

「シーン内のメッシュを全部調べる」「フェースを1枚ずつ見る」といった処理には、イテレータを使います。while not it.isDone(): で回し、最後に it.next() で次へ進めるのが基本の形です。

次のコードは、シーン内のメッシュを MItDag で集め、MItMeshPolygon で5角形以上のフェース(N-gon)を探して選択します。

def iter_mesh_paths():
    """シーン内のメッシュシェイプを、中間オブジェクトを除いて順に返す"""
    it = om.MItDag(om.MItDag.kDepthFirst, om.MFn.kMesh)
    while not it.isDone():
        path = it.getPath()
        if not om.MFnDagNode(path).isIntermediateObject:
            yield path
        it.next()

def find_ngons(path):
    """5角形以上のフェース番号のリストを返す"""
    faces = []
    it = om.MItMeshPolygon(path)
    while not it.isDone():
        if it.polygonVertexCount() > 4:
            faces.append(it.index())
        it.next()
    return faces

hits = []
for path in iter_mesh_paths():
    name = path.partialPathName()
    hits += [f"{name}.f[{i}]" for i in find_ngons(path)]

if hits:
    cmds.select(hits)
    om.MGlobal.displayWarning(f"5角形以上のフェースが {len(hits)} 枚あります")
else:
    om.MGlobal.displayInfo("5角形以上のフェースはありません")

MItDag の2つ目の引数に om.MFn.kMesh を渡すと、メッシュだけをたどります。頂点を1つずつ見るなら MItMeshVertex があり、position() で座標、getConnectedVertices() でつながっている頂点の番号を取れます。

最後の選択だけ cmds の select を使っているのは、select がアンドゥに対応したコマンドだからです。「調べるのは OpenMaya、シーンを変えるのは cmds」と分けておくと、後の章で説明するアンドゥの問題を避けやすくなります。

配列の一括取得で書く

同じ判定は、MFnMesh の getVertices() でも書けます。戻り値の1つ目が「フェースごとの頂点数」の配列なので、リスト内包表記で一度に判定できます。

counts, _ = om.MFnMesh(path).getVertices()
faces = [i for i, n in enumerate(counts) if n > 4]

イテレータは、フェースの面積や中心など要素ごとの情報を順に取り出せるのが利点です。頂点数を数えるだけのような単純な集計なら、一括取得のほうがコードは短くなります。どちらが速いかはメッシュと処理で変わるので、次の章の方法で測って決めます。

cmds との速度差を測る

ひとことで:差を生むのは呼び出し回数です。頂点ごとに cmds を呼ぶ書き方が最も遅くなります。

cmds は呼び出すたびに、名前(文字列)からノードを探し、結果を Python のリストに詰め直します。頂点ごとに呼べば、その手間が頂点数の分だけ積み上がります。OpenMaya の getPoints() は1回の呼び出しで全頂点を受け取るので、頂点数が多いほど差が開きます。

どれだけ差が出るかは、マシンや Maya のバージョン、処理の内容で変わるので、手元で測るのが確実です。次のコードは、約4万頂点の球で、全頂点のワールド座標を取る時間を比べます。

import time

sphere = cmds.polySphere(subdivisionsX=200, subdivisionsY=200)[0]   # 約4万頂点
count = cmds.polyEvaluate(sphere, vertex=True)

t0 = time.perf_counter()
by_cmds = [cmds.pointPosition(f"{sphere}.vtx[{i}]", world=True) for i in range(count)]
t1 = time.perf_counter()
by_api = om.MFnMesh(get_mesh_path(sphere)).getPoints(om.MSpace.kWorld)
t2 = time.perf_counter()

print(f"頂点数 {count} / cmds {t1 - t0:.3f} 秒 / OpenMaya {t2 - t1:.3f} 秒")

比べるときは、同じシーン・同じ頂点数で数回実行して傾向を見ます。数十個のノードを触るだけの処理なら、読みやすい cmds のままで十分です。OpenMaya に書き換えるのは、測って遅いと分かった処理だけにします。

夕宮たいだ

ほよ? 同じ座標を取るだけなのに、呼び方で時間が変わるんだぁ。まずは測ってから、だねぇ。

アンドゥの扱い:変更はコマンドとして記録する

ひとことで:OpenMaya で直接シーンを書き換えても、Maya のアンドゥ履歴には残りません。

MFnMesh の setPoints() のような書き込みメソッドを Script Editor から直接呼ぶと、その変更はアンドゥキュー(Ctrl+Z で戻せる操作の履歴)に積まれません。Ctrl+Z を押しても、書き換えより前の操作が取り消されるだけです。公式ドキュメントも、DG(Dependency Graph)やそのノードを変更するコマンドは、Maya の内部状態とアンドゥの履歴がずれないよう、取り消しの処理を実装すべきだと説明しています。

対処は2通りです。

  • 書き込みは cmds に任せる:OpenMaya で調べ、変更は cmds で行う。複数の cmds を1回のアンドゥにまとめるなら、cmds.undoInfo() のチャンクで囲む
  • アンドゥ対応のコマンドを作る:OpenMaya で書き換える処理を、MPxCommand を継承したプラグインのコマンドにする
夕宮たいだ

API で直接書き換えて、Ctrl+Z で戻らないツール……それをアーティストさんに渡すの、絶対ダメだよ! ほんとに事故るからねぇ。

cmds に任せてチャンクでまとめる

次のコードは、前の章の iter_mesh_paths() で見つけたメッシュのうち、トランスフォーム名が _geo で終わっていないものに接尾辞を付けます。変更は cmds の rename で行い、全体を1回の Ctrl+Z で戻せるようにチャンクで囲んでいます。

renames = {}
for path in iter_mesh_paths():
    xform = om.MDagPath(path).pop()                  # シェイプの1つ上=トランスフォーム
    short = om.MFnDagNode(xform).name()
    if not short.endswith("_geo"):
        renames[xform.fullPathName()] = f"{short}_geo"

cmds.undoInfo(openChunk=True, chunkName="addGeoSuffix")
try:
    # 深い階層から先に変えて、親の名前変更で子のフルパスがずれないようにする
    for full, new in sorted(renames.items(), key=lambda kv: kv[0].count("|"), reverse=True):
        cmds.rename(full, new)
finally:
    cmds.undoInfo(closeChunk=True)                   # 例外が出ても必ず閉じる

undoInfo のチャンクは、使い方を誤るとアンドゥキューをおかしな状態にすると公式に注意書きがあります。開いたら必ず閉じるよう、try と finally で囲みます。命名の決め方そのものはアセット命名・バージョン規則|チームで事故らないルール作りで扱っています。

MPxCommand でアンドゥ対応のコマンドを作る

OpenMaya で書き換えたいときは、プラグインのコマンドにします。次のファイルを oy_offset_y.py として保存すると、選択中のメッシュの頂点をオブジェクト座標の Y 方向にずらす oyOffsetY コマンドになります。書き換える前後の座標を持っておき、undoIt() で元に戻す作りです。プラグインは単独のファイルで動く必要があるので、前の章の to_mesh_path() も同じファイルに入れています。

import maya.api.OpenMaya as om

def maya_useNewAPI():
    """このプラグインが API 2.0 を使うことを Maya に知らせる"""
    pass

def to_mesh_path(path):
    if path.hasFn(om.MFn.kMesh):
        return path
    for i in range(path.numberOfShapesDirectlyBelow()):
        shape = om.MDagPath(path)
        shape.extendToShape(i)
        if shape.hasFn(om.MFn.kMesh) and not om.MFnDagNode(shape).isIntermediateObject:
            return shape
    return None

class OffsetYCmd(om.MPxCommand):
    kName = "oyOffsetY"
    kFlag, kFlagLong = "-oy", "-offsetY"

    def __init__(self):
        om.MPxCommand.__init__(self)
        self.edits = []                       # (経路, 変更前の座標, 変更後の座標)

    @staticmethod
    def creator():
        return OffsetYCmd()

    @staticmethod
    def create_syntax():
        syntax = om.MSyntax()
        syntax.setObjectType(om.MSyntax.kSelectionList)
        syntax.useSelectionAsDefault(True)    # 対象を省略したら選択中のものを使う
        syntax.addFlag(OffsetYCmd.kFlag, OffsetYCmd.kFlagLong, om.MSyntax.kDouble)
        return syntax

    def doIt(self, args):
        db = om.MArgDatabase(self.syntax(), args)
        offset = db.flagArgumentDouble(self.kFlag, 0) if db.isFlagSet(self.kFlag) else 1.0
        sel = db.getObjectList()
        for i in range(sel.length()):
            try:
                path = to_mesh_path(sel.getDagPath(i))
            except TypeError:                 # DAG ノードでない項目は飛ばす
                continue
            if path is None:
                continue
            before = om.MFnMesh(path).getPoints(om.MSpace.kObject)
            after = om.MPointArray(before)
            for p in after:
                p.y += offset
            self.edits.append((path, before, after))
        self.redoIt()

    def redoIt(self):
        for path, _, after in self.edits:
            om.MFnMesh(path).setPoints(after, om.MSpace.kObject)

    def undoIt(self):
        for path, before, _ in self.edits:
            om.MFnMesh(path).setPoints(before, om.MSpace.kObject)

    def isUndoable(self):
        return True

def initializePlugin(plugin):
    om.MFnPlugin(plugin, "oyasumi", "1.0").registerCommand(
        OffsetYCmd.kName, OffsetYCmd.creator, OffsetYCmd.create_syntax)

def uninitializePlugin(plugin):
    om.MFnPlugin(plugin).deregisterCommand(OffsetYCmd.kName)
  • maya_useNewAPI を定義すると、Maya はこのプラグインに API 2.0 のオブジェクトを渡します
  • doIt() は実行時に1回だけ呼ばれます。ここで引数を読み、変更前後の座標を用意してから redoIt() を呼びます
  • redoIt() はやり直し(Edit > Redo)のたびに、undoIt() は取り消しのたびに呼ばれます。isUndoable() が True を返すと、コマンドがアンドゥキューに積まれます
  • フラグの短い名前は4文字未満、長い名前は4文字以上にします

プラグインは、ファイルのパスを .py まで含めて loadPlugin に渡すと読み込めます。読み込まれたかどうかは Windows > Settings/Preferences > Plug-in Manager でも確認できます。

cmds.loadPlugin("C:/tools/maya/plug-ins/oy_offset_y.py")   # Python のプラグインは .py まで書く
cube = cmds.polyCube()[0]
cmds.select(cube)
cmds.oyOffsetY(offsetY=0.5)      # 選択中のメッシュの頂点を 0.5 上げる
# ここで Ctrl+Z を押すと元の位置に戻る

毎回パスを書く代わりに、プラグインのフォルダを環境変数 MAYA_PLUG_IN_PATH に登録しておく方法もあります(Maya.env に書くのが公式の手順です)。コードを直したら、cmds.unloadPlugin("oy_offset_y") で外してから読み込み直します。コマンド名に社内の接頭辞(ここでは oy)を付けておくと、ほかのプラグインとの名前の衝突を避けられます。

夕宮たいだ

Ctrl+Z でちゃんと元に戻るの、ほら、安心でしょ? これならアーティストさんにも渡せるねぇ。

よくある失敗と対処

ひとことで:プロパティと関数の取り違え、経路の取り違え、アンドゥ漏れの3つが特に多い失敗です。

fn.numVertices() と書いてエラーになる

API 2.0 では、件数の多くがプロパティです。fn.numVertices のように () を付けずに読みます。1.0 のサンプルを 2.0 に書き直すときに起きやすい失敗です。

1.0 と 2.0 のオブジェクトを混ぜてエラーになる

maya.OpenMaya と maya.api.OpenMaya はクラス名がほぼ同じなので、どちらの MObject かを取り違えがちです。1つのファイルは 2.0 に統一し、別名も om のように固定します。

ワールド座標を取ろうとしてエラーになる

MObject から作った関数セットでは、ワールド空間の計算ができません。om.MSpace.kWorld を使うときは、MDagPath から関数セットを作ります。

変形前の座標が返ってくる

スキンやブレンドシェイプを使ったメッシュでは、中間オブジェクトのシェイプを読むと変形前の形が返ってきます。om.MFnDagNode(path).isIntermediateObject で除外します。

同じ名前のノードが複数あって対象が定まらない

階層が違えば、同じ短い名前のノードが存在できます。短い名前だけでは、どのノードを指すのかがあいまいになります。名前で MSelectionList に追加するときは、|group1|pCube1 のようなフルパスで渡します。フルパスは MDagPath の fullPathName() で取れます。

取っておいた MObject が使えなくなる

MObject はノードを指すハンドルなので、ノードが削除されると無効になります。長く持ち続けるなら MObjectHandle で包み、使う前に isValid() で確かめます。

プラグインに 1.0 のオブジェクトが渡される

プラグインのファイルに maya_useNewAPI がないと、Maya はそのプラグインに 1.0 のオブジェクトを渡します。公式サンプルと同じく、ファイルの先頭で定義しておきます。

夕宮たいだ

ぁぅ……プロパティと関数の違い、最初はほんとによく間違えるんだぁ。エラーが出たら、まずリファレンスで書き方を確かめてねぇ。

チェックリスト

  • [ ] maya.api.OpenMaya(API 2.0)で統一し、1.0 と混ぜていない
  • [ ] DAG ノードは MDagPath で受け取り、ワールド座標はその経路から取っている
  • [ ] 中間オブジェクトのシェイプを処理から外している
  • [ ] 件数のプロパティ(numVertices など)に () を付けていない
  • [ ] 書き換える前に、cmds 版との速度を測って効果を確かめた
  • [ ] シーンを変える処理は、cmds のチャンクか MPxCommand でアンドゥできる
  • [ ] プラグインに maya_useNewAPI を定義し、コマンド名に接頭辞を付けた

次に読む記事

夕宮たいだ

ふぁ……OpenMaya、ちょっと身近になった……かなぁ? 「測ってから使う」「変更はアンドゥできる形で」、この2つだけ覚えて帰ってねぇ。

よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!

この記事を書いた人

目次