あいさつ
はじめまして、Qiitaアカウントを作った記念に
Maya Advent Calender2023の23日目に参加させていただきました
今回はたぶん自分にしか需要がなさそうですが
Maya内でのミラーアニメーションツールを作成したときにあれこれ試行錯誤したことをいろいろと書いていこうと思います
検証環境
AutoDesk Maya 2023.3
やりたかったこと
この2つです
シンプルなワールド空間での指定平面ミラーポーズを得る
まずひとつ目のワールド空間のシンプルなミラーを実装します
といっても数学そのものは苦手なうえ、数ヶ月の業務時間外の検証と数多の壁にぶち当たる経験を経て
挫折しかけた途中で偉大なる先人様のオープンソースであるryusas様のCyMelを発見
今回はシンプルなワールド空間ミラーはcymelの利用で実現しました
※挫折しかけたときにいろいろと試行錯誤したものは後述しています
import cymel.main as cm
import maya.api.OpenMaya as om2
from enum import Enum, IntEnum, auto
class MirrorPlane(Enum):
"""ミラー平面の指定用列挙型"""
XY = auto()
YZ = auto()
XZ = auto()
class MirrorAxis(IntEnum):
"""ミラーの指定軸Index"""
X = 0
Y = 1
Z = 2
def mirrorMatrix(
original,
target,
mirrorPlane: MirrorPlane = MirrorPlane.YZ,
withTrans: bool = True
):
"""
マトリックスを指定する平面方向にミラーする
"""
originalMatrix: om2.MMatrix = original.getMatrix('world')
# cymelのMatrixクラスをインスタンス
cyMat = cm.Matrix(originalMatrix)
if mirrorPlane.value == MirrorPlane.XY.value:
mirrorAxis = MirrorAxis.Z
elif mirrorPlane.value == MirrorPlane.YZ.value:
mirrorAxis = MirrorAxis.X
else: # MirrorPlane.XZ
mirrorAxis = MirrorAxis.Y
# cymel Matrixクラス内のミラー関数を利用
mirrored_Matrix = cyMat.mirror(mirrorAxis, t=withTrans)
# Pymelなど 検証ではPymelライクな自作クラスで回転セット
target.setRotation(mirroredMatrix.asDegrees(), "world")
# 自作クラスで移動セット pymel PyNodeとほぼ一緒です
if withTrans:
target.setTranslation(mirroredMatrix.asTranslation(), "world")
オブジェクトがYup向きのトランスフォームのミラー
左から右のトランスフォームに実行した例の画像です
つまづきポイント
今回のトランスフォームのミラーリングについて海外記事含めて資料を求めWebを巡る旅をしたのですが、
Maya等3DCGのアニメーションに使うミラーと一般的なマトリクスミラーは性質が異なりました
マトリクスの平面ミラーリングを学習する
平面ミラー行列とはつまるところ、任意の1軸方向にミラーリングした行列です
YZ平面でミラーする場合、Xベクトル方向に反転すればいいわけですから計算式は
Myz =
\left[
\begin{matrix}
1, 0, 0 \\
0, 1, 0 \\
0, 0, 1
\end{matrix}
\right]
・
\left[
\begin{matrix}
-1, 0, 0 \\
0, 1, 0 \\
0, 0, 1
\end{matrix}
\right]
になります
しかし、Mayaのトランスフォームマトリクスで取り扱う場合はここで問題が発生します
崩れないXYZの強い絆
たとえばMayaのトランスフォームマトリクスにおいては
Yベクトルの左隣(且つ90度)には必ずXベクトルがある状態でないとスケールやシアーがはいっている
と判断されていると推測されます
つまりscaleに値が入った状態のマトリクスが得られてしまい
これをトランスフォームに適応すると意図しないスケールがはいります
上の画像は右のトランスフォームのマトリクスに対して、
Xベクトルだけが-1の単位行列をかけるた結果を左の同じものに適応した結果です
ScaleZにマイナス値が入ってしまいました
Mayaのマトリクスを求める計算によるものか?
Mayaのトランスフォームのマトリクス算出方法についてしらべると、
計算式的にはスケールから計算を行う記述や情報がありました
逆にマトリクスから移動回転スケールを求める場合も、
同様に移動をうちけし=>スケール計算する
この段階で回転行列の各軸のベクトルが使われてしまうがゆえに
比較したときにスケールともっというとシアーも入ってしまうのではないかと思っています
回転行列を利用しているのか検証
検証してみると回転行列部分のベクトルの長さをかえると、
結果を適応したトランスフォームはスケールとシアーもはいってしまいました
検証コード
import maya.api.OpenMaya as om2
import maya.cmds as cmds
_OM2M = om2.MMatrix
def getMatrix(dagPath, space="object"):
"""
ノードのマトリクスを取得する。
"""
if space == "world":
nodeMatrix = _OM2M(
cmds.getAttr("{}.worldMatrix".format(dagPath.fullPathName()))
)
elif space == "object":
mfnTrfm = om2.MFnTransform(dagPath)
nodeMatrix = mfnTrfm.transformation().asMatrix()
else:
raise ValueError("[Error] 不明な指定空間名です: [{}]".format(space))
return nodeMatrix
def setMatrix(dagPath, matrix, space="object"):
"""
ノードのマトリクスを設定する。
"""
nodeName = dagPath.fullPathName()
if space == "world":
cmds.xform(nodeName, ws=True, m=matrix)
elif space == "object":
cmds.xform(nodeName, os=True, m=matrix)
else:
raise ValueError("[Error] 不明な指定空間名です: [{}]".format(space))
def test_matrix_to_transform():
# ノードを2つ選択して実行
# 前者のマトリクスを、ベクトルに長さを持つ行列とかけあわせ後者に適応する
sellist = om2.MGlobal.getActiveSelectionList()
original = sellist.getDagPath(0)
target = sellist.getDagPath(1)
originalMatrix = getMatrix(original, "world")
originalMatrix *= _OM2M([
-2, 0, 0, 0,
0, 1, 0, 0,
0, 0, 1, 0,
0, 0, 0, 1
])
setMatrix(target, originalMatrix, "world")
test_matrix_to_transform()
また、クォータニオンを調べるにあたって、
回転行列のみでスケールを表現することもできるという記述がありました
S = \left[
\begin{matrix}
s, 0, 0 \\
0, s, 0 \\
0, 0, s
\end{matrix}
\right]
Mayaのスケール計算も同じかは検証していませんが、
今回の検証ではXベクトルの長さを増やしてしまったので
横方向に伸びたと言う結果になったと思っています
やはり回転行列部分のベクトル方向や長さはかなり重要そうです
そのまま適応はできない
このままではworldMatrixアトリビュートへの接続やxformのセットは使えません
移動回転を取り出して適応しますが、それをすると
このようにスケールがない移動回転に変換しても、クオータニオン変換の時点で
XYZの関係性を保持した状態に変換され先程のスケールZが前後逆転してしまいます
一度方針を変えるため、
マトリクスのミラーをやめてクオータニオンのYZ平面ミラーも試しました
クオータニオンでYZ平面ミラーを行う場合、
こちらかなり直感的で以下で良いようです
import maya.api.OpenMaya as om2
_OM2Q = om2.MQuaternion
yzPlMirrorQ = _OM2Q(q.x, -q.y, -q.z, q.w);
動作検証コード
import maya.api.OpenMaya as om2
import maya.cmds as cmds
import math
_OM2M = om2.MMatrix
_OM2Q = om2.MQuaternion
def getMatrix(dagPath, space="object"):
"""
ノードのマトリクスを取得する。
"""
if space == "world":
nodeMatrix = _OM2M(
cmds.getAttr("{}.worldMatrix".format(dagPath.fullPathName()))
)
elif space == "object":
mfnTrfm = om2.MFnTransform(dagPath)
nodeMatrix = mfnTrfm.transformation().asMatrix()
else:
raise ValueError("[Error] 不明な指定空間名です: [{}]".format(space))
return nodeMatrix
def getspace(space):
"""
maya.api.OpenMaya.MSpaceの「world」と「object」Enumを返す。
"""
if space == "world":
m_space = om2.MSpace.kWorld
elif space == "object":
m_space = om2.MSpace.kTransform
else:
raise RuntimeError("please object space of matrix.")
return m_space
def eulerToDegress(eulerRot):
return [
math.degrees(angle) for angle in (eulerRot.x, eulerRot.y, eulerRot.z)
]
def matrixToQuatMirror(matrix):
mftMatrix = om2.MTransformationMatrix(matrix)
quatRot = mftMatrix.rotation(asQuaternion=True)
quatRot.normalizeIt()
quatRot = _OM2Q(-quatRot.x, quatRot.y, quatRot.z, quatRot.w)
mftMatrix.setRotation(quatRot)
return mftMatrix.asMatrix()
def decomposeMatrix(matrix, rotOrder=0, space="object"):
"""
渡されたマトリクスの要素をトランスフォームの値へ分解する。 現在移動回転のみ
"""
m_space = getspace(space)
mftMatrix = om2.MTransformationMatrix(matrix)
# Decomposes a MMatrix.
trans = mftMatrix.translation(m_space)
quatRot = mftMatrix.rotation(asQuaternion=True)
quatRot.normalizeIt()
eulerRot = quatRot.asEulerRotation()
eulerRot.reorderIt(rotOrder)
rot = eulerToDegress(eulerRot)
return trans, rot
def setTraRot(dagPath, tra, rotate, space="object"):
"""
ノードの移動・回転を設定する。
"""
nodeName = dagPath.fullPathName()
if space == "world":
cmds.xform(nodeName, ws=True, t=tra, ro=rotate)
elif space == "object":
cmds.xform(nodeName, os=True, t=tra, ro=rotate)
else:
raise ValueError("[Error] 不明な指定空間名です: [{}]".format(space))
def test_matrix_to_transform():
sellist = om2.MGlobal.getActiveSelectionList()
original = sellist.getDagPath(0)
target = sellist.getDagPath(1)
originalMatrix = getMatrix(original, "world")
mirroredMatrix = matrixToQuatMirror(originalMatrix)
tra, rot = decomposeMatrix(mirroredMatrix, 0, "world")
tra = [tra[0]*-1, tra[1], tra[2]]
setTraRot(target, tra, rot, "world")
test_matrix_to_transform()
クォータニオンについてあまり詳しく調べたことがなかったのですが、
クォータニオンはこれがそのまま回転行列として扱えるようなので、
回転行列のYZのベクトルにそって反転回転させることと同義になるようです
これでYupのトランスフォームは概ねうまくいくのですが、
後述する壁にぶちあたり再びマトリクスの回転行列をどうにかしようともがいていた矢先
cymelに出会います
変換などの関数が豊富なcymelのミラーを使うことに決めたきっかけになりました
壁にぶちあたる
cymelを使い、「いえー!ミラーなんて楽勝だぜー」と思っていた矢先強敵があらわれます
Xupで入っている骨の軸通りにむいたトランスフォームの存在です
理由は簡単でオブジェクト的にはXupを向いているつもりでも基本ワールド空間はYupであることは
覆らないのでY方向が横に向いちゃってる状態でも正常にYupでの姿勢のミラーが発生しているのでした
計算上は正しい・・・となると
発想の転換
軸向きをYup軸に入れ替えたマトリクスに一度変換してしまおう
長いので色々省略していますがコード
import maya.api.OpenMaya as om2
import cymel.main as cm
_OM2M = om2.MMatrix
class MirrorPlane(Enum):
"""ミラー平面の指定用列挙型"""
XY = auto()
YZ = auto()
XZ = auto()
class MirrorAxis(IntEnum):
"""ミラーの指定軸Index"""
X = 0
Y = 1
Z = 2
class UpType(Enum):
Xup = auto()
Yup = auto()
Zup = auto()
class CustomMatrix:
"""4x4の行列から回転行列部分を入れ替えるだけのクラス"""
__valueError = ValueError(
"sequence of 16 float values or four tuples of four float values each."
)
__sequence: list
__xVec: list
__yVec: list
__zVec: list
__tVec: list
def __init__(self, sequence) -> None:
"""
Args:
sequence (list):
マトリクスを表す16要素の配列または
ベクトル要素ごとに区切った入れる
または MMatrix
"""
if isinstance(sequence, list):
if len(sequence) != 16 and len(sequence) != 4:
raise self.__valueError
# 4つのタプルが来る場合は中身を取り出していく
if len(seaquence) == 4:
tupleSequence: list = []
for row in sequence:
if len(row) != 4:
raise self.__valueError
for column in row:
tupleSequence.append(column)
self.__sequence = tupleSequence
# 16要素の配列の場合はそのままいれる
else:
self.__sequence = sequence
# MMatrixの場合は配列として扱う
elif isinstance(sequence, _OM2M):
self.__sequence = sequence
else:
raise self.__valueError
self.__xVec = [
self.__sequence[0],
self.__sequence[1],
self.__sequence[2],
self.__sequence[3],
]
self.__yVec = [
self.__sequence[4],
self.__sequence[5],
self.__sequence[6],
self.__sequence[7],
]
self.__zVec = [
self.__sequence[8],
self.__sequence[9],
self.__sequence[10],
self.__sequence[11],
]
self.__tVec = [
self.__sequence[12],
self.__sequence[13],
self.__sequence[14],
self.__sequence[15],
]
@property
def XVec(self) -> list:
return self.__xVec
@property
def YVec(self) -> list:
return self.__yVec
@property
def ZVec(self) -> list:
return self.__zVec
@property
def TVec(self) -> list:
return self.__tVec
@property
def Sequence(self) -> list:
"""
MMatrixをそのままいれてしまうので配列化
"""
return [
self.__sequence[0],
self.__sequence[1],
self.__sequence[2],
self.__sequence[3],
self.__sequence[4],
self.__sequence[5],
self.__sequence[6],
self.__sequence[7],
self.__sequence[8],
self.__sequence[9],
self.__sequence[10],
self.__sequence[11],
self.__sequence[12],
self.__sequence[13],
self.__sequence[14],
self.__sequence[15],
]
def __setSeaquence(self):
sequence = []
for row in self.get4x4():
for column in row:
sequence.append(column)
self.__sequence = sequence
def get4x4(self):
"""入れ子の4x4の配列を返す"""
return [self.__xVec, self.__yVec, self.__zVec, self.__tVec]
def composeXup2Yup(self):
self.swapXtoY()
self.swapXtoZ()
def composeYup2Xup(self):
self.swapXtoZ()
self.swapXtoY()
def composeZup2Yup(self):
self.swapZtoY()
self.swapXtoY()
def composeYup2Zup(self):
self.swapXtoY()
self.swapZtoY()
def swapXtoY(self):
oldX = self.__xVec
oldY = self.__yVec
self.__xVec = oldY
self.__yVec = oldX
self.__setSeaquence()
def swapZtoY(self):
oldZ = self.__zVec
oldY = self.__yVec
self.__zVec = oldY
self.__yVec = oldZ
self.__setSeaquence()
def swapXtoZ(self):
oldX = self.__xVec
oldZ = self.__zVec
self.__xVec = oldZ
self.__zVec = oldX
self.__setSeaquence()
def asMatrix(self):
"""MMatrixに変換"""
return _OM2M(self.__sequence)
def mirrorMatrix(
original,
target,
mirrorPlane: MirrorPlane = MirrorPlane.YZ,
withTrans: bool = True
):
# ~ 省略 ~
# ユーザーにXup指定をさせる(現在の姿勢の軸向きなどからXupであることを判断するのは難しい)
upAxisType: UpType = UpType.Xup
isNotYup = upAxisType.value != UpType.Yup.value
if isNotYup:
switchMatrix = CustomMatrix(originalMatrix)
# 一度Yupマトリクスに変換する
if upAxisType.value == UpType.Xup.value:
switchMatrix.composeXup2Yup() # 各軸のVector値を入れ替える
else:
switchMatrix.composeZup2Yup()
cyMat = cm.Matrix(switchMatrix.asMatrix())
print("up Axis => {}".format(upAxisType.name))
mirrored_Matrix = cyMat.mirror(mirrorAxis, t=withTrans)
逆転される対象に着目する
今回ミラーしようとしていたトランスフォームは主軸Xで側面軸Yでした
かつよくみるとZベクトルが反対を向いています
これ自体はXとYを正方向の設定で骨の方向づけをすれば、
標準的な手順では正しい状態といえます
Mayaは右手座標系のソフトウェアなので、
XYのベクトル方向からZベクトルを割り出すとここまでは自然です
更にまずいことにXが上をむいた状態で成立するYupに変換すると、
本来横軸であるはずのYが正面になってしまいます(それ以外だとこの形は成立しない)
この結果反転先でZ軸が逆転します
軸入れ替えの際にここをカバーしてもいいのですが、
複雑になりそうなので今回は別の対応を考えます
ミラーのnegAxisにZを指定する
ということで、cymelのmatrix.mirror関数のお世話になります
cymelのmirror関数には本当に使う側にとってありがたい、
ミラーしたあとのマトリクスに対して1軸を逆転させるオプションの引数がありますのでそこへ
Zベクトルを指定して実行するように対応してみます
# ~ 省略 ~
def mirrorMatrix(
original,
target,
mirrorPlane: MirrorPlane = MirrorPlane.YZ,
withTrans: bool = True
):
def _checkXupMirrorType(upAxisType: UpType, mirrorAxis: MirrorAxis):
"""Xupの場合のミラー平面の判定"""
if upAxisType.value == UpType.Xup.value: # 対象がXupである
# YZ平面が指定されている、またはXZ平面が指定されている
return (
mirrorAxis.value == MirrorAxis.X.value
or mirrorAxis.value == MirrorAxis.Y.value
)
return False
# ~ 省略 ~
if _checkXupMirrorType(upAxisType, mirrorAxis):
# XupでYZまたはXZ平面指定の場合はミラー後の結果からZVectorを逆転する
mirrored_Matrix = cyMat.mirror(
mirrorAxis, negAxis=MirrorAxis.Z, t=withTrans
)
else:
mirrored_Matrix = cyMat.mirror(mirrorAxis, t=withTrans)
いろいろ試したんですが、この問題はYZ,XZの平面のみで発生しそうです
試す
成功です。
本当にありがとうございます、cymel様にはもう足をむけて寝れません
親空間となるトランスフォームを指定して、その親空間でのミラーポーズを得る
前提条件として、
左右のトランスフォームは中央の空間親とトランスフォーム階層的な親子関係はないものを想定します
例:
ルートが90度横に向いているキャラクターのリグの左右の手のIKコントローラーを
ルートの向きを基準にミラーする場合など
計算式を導き出す
手続きのイメージ
- 対象のワールドマトリクスを指定空間にいれた想定のローカルマトリクスを得る
- YZ平面にミラーする
- 指定空間に戻す
資料によると、マトリクスの親子関係付けは親の行列をかける
Mworld = Mlocal * MparentW
親からワールドに出すには逆行列をかける
Mworld = Mlocal * MparentW.inverse()
のようです
しかしここでまた壁にぶち当たります。
ローカルマトリクスの得かたがわからない
単純に親子付け計算をした状態のマトリクスは「トランスフォームの親子付けと違いワールド空間にいる状態」
であることに変わりはありません
そうすると指定している親空間のXベクトル方向がワールド空間のXベクトル方向と
違う姿勢を向いている場合は移動ですら別の座標にいってしまいます
ミラーした姿勢が本来得たかった姿勢ではなくなります
親空間においても現在の姿勢を維持した相対的な姿勢を取得したいので
親にいれてからワールド空間にまた取り出すのかなと計算式を眺めると
Mworld = Mlocal * Mparent * Mparent.inverse()
見ての通り元の姿勢に戻るだけです
でも、とりあえずものはためしだということで一旦そのまま実装
import maya.api.OpenMaya as om2
import cymel.main as cm
_OM2M = om2.MMatrix
# ミラー対象の元の姿勢
originalMatrix: _OM2M = mainNode.getMatrix(space="world")
if spaceObject:
parentMatrix: _OM2M = spaceObject.getMatrix(space="world")
# 親空間に入れる(?)
originalMatrix *= parentMatrix * parentMatrix.inverse()
# さきほどのワールド空間のミラーを少し改造
mirroredMatrix: cm.Matrix = mirrorMatrix(
originalMatrix=originalMatrix,
mirrorPlane=mirrorPlane,
upAxisType=upAxisType,
withTrans=withTrans,
)
if spaceObject:
mirroredMatrix = _OM2M(mirroredMatrix)
# 親空間に戻す
mirroredMatrix *= parentMatrix
# 移動回転を取り出すために再度cymelMatrixへ
mirroredMatrix = cm.Matrix(mirroredMatrix)
# cymel.Matrixにはオイラーの角度を返す関数がある
target.setRotation(mirroredMatrix.asDegrees(), "world")
# Translate
if withTrans:
# 同じく移動値値を返す関数がある
target.setTranslation(mirroredMatrix.asTranslation(), "world")
予想どおり向きも位置関係もおかしな場所にいます
ただ、親空間が原点で回転[0,0,0]の姿勢であったと仮定すると、
ワールド空間でのミラー姿勢を親子付けした姿勢であることは確かなようです
親の姿勢を打ち消す
ということは元のワールド空間の姿勢から親の姿勢が打ち消せれば、
親空間内において元の姿勢を維持したローカルマトリクスが得られることにたどり着きました
純粋に今の状態をローカルマトリクスだと仮定すればよかったのでした
Mrelative = Mworld * Mparent.inverse()
さっそく実装します
import maya.api.OpenMaya as om2
import cymel.main as cm
_OM2M = om2.MMatrix
# ミラー対象の元の姿勢
originalMatrix: _OM2M = mainNode.getMatrix(space="world")
# 親の姿勢を打ち消す
if spaceObject:
parentMatrix: _OM2M = spaceObject.getMatrix(space="world")
originalMatrix *= parentMatrix.inverse()
# さきほどのワールド空間のミラーを少し改造
mirroredMatrix: cm.Matrix = mirrorMatrix(
originalMatrix=originalMatrix,
mirrorPlane=mirrorPlane,
upAxisType=upAxisType,
withTrans=withTrans,
)
if spaceObject:
mirroredMatrix = _OM2M(mirroredMatrix)
mirroredMatrix *= parentMatrix
# 移動回転を取り出すために再度cymelMatrixへ
mirroredMatrix = cm.Matrix(mirroredMatrix)
target.setRotation(mirroredMatrix.asDegrees(), "world")
# Translate
if withTrans:
target.setTranslation(mirroredMatrix.asTranslation(), "world")
もういまならはだかで逆立ちして町内を一周できそうな気分です
発想の問題
マトリクスの親子付けのイメージは空間にいれたり出したりと、
トランスフォーム的な階層が変わるイメージが非常につよかったですが、
マトリクスを取り扱うときのイメージがかわりました
1.常にワールド空間にいることを想定する
2.姿勢の掛け合わせをおこなうイメージを想定する
現在ではこのように考えることにしています
また今回のワールド空間の姿勢を維持したまま相対的なローカルマトリクスを得る計算は、
トランスフォームの姿勢計算以外にもシェーダーなどでも使えるのではないかと思いました
ただいまのところ、いつ使うのかというのは正直不明です・・・・
いいアイデアを思いついた方がいらっしゃれば是非ご活用ください
まとめ
1, シンプルなワールド空間での指定平面ミラーポーズを得る
こちらはMayaでミラーツール実装を行う場合、cymelのMatrix.mirror関数のご利用をおすすめします
あらゆる状況のミラーが想定されており
up軸と移動オプション指定のためのGUIをつくるくらいですぐにデザイナーに提供可能です
2, 親空間となるトランスフォームを指定して、その親空間でのミラーポーズを得る
こちらは対象のワールドマトリクスに指定した親の逆ワールドマトリクスをかけてミラー
最後に親のワールドマトリクスをかける
これだけで親を基準としたミラーリングが実行できました
かなりピンポイントな話でしたが、
なにかのアイデアの参考になれば幸いです
参考文献
感謝感激恐悦至極
こちらはコード内にて利用させていただいています
ryusas様のCyMel