前言

  最近因为比较特殊的原因,工作上突然闲下来了,于是我就去研究了我们组做的 Maya 毛发工具。
  学习一下 Maya 做笔刷有哪些坑。

  这次我的主要目的是模仿 XGen 的毛发笔刷效果,通过最小案例的实现,探讨不同的实现方案。

官方文档: XGen Interactive Grooming

  上面的视频就是 XGen 实现的笔刷效果,对于毛发制作非常丝滑好用。
  只可惜这个笔刷不能对曲线直接生效。

  上面是我用 C++ 写的曲线笔刷,下面我也会来探讨如何用 Python OpenMaya 结合 Qt 开发笔刷的流程。
  具体代码已经开源到 https://github.com/FXTD-ODYSSEY/Maya-CurveBrush
  C++ 插件提供了 2020 - 2023 支持
  Python 插件有 om1_curve_brush.pyom2_curve_brush.py

Maya C++ 笔刷开发流程

Maya C++ MPxContext

  什么是 Maya Context ? 官方文档说明
  Maya Context 就是一个开放的接口,可以用于自定义 鼠标 在 Viewport 上执行的逻辑,实现 绘制 修改选择物体 等操作。

MPxContext 官方文档

  上面的链接是一个 Maya Devkit 里面的案例 devkit\plug-ins\marqueeTool\marqueeTool.cpp
  Maya CMake 构建 C++ 插件编译环境 我的这篇文章有提到如何将 devkit 的源码编译生成 mll

maya2020 - marqueeTool.mll

  这里提供 Maya2020 windows 版本的 mll 插件
  Maya 加载 mll 插件

1
2
3
import maya.cmds as cmds
ctx = cmds.marqueeToolContext()
cmds.setToolTo(ctx)

  加载mll 插件后,可以使用上面的代码激活 Context

  上面实现的效果和默认的 框选物体是一样的。
  只是框的颜色变成了自定义的 黄色。


  实现这个 context 需要继承实现两个类,一个是 MPxContext 另一个是 MPxContextCommand
  MPxContext 类定义了鼠标拖拽 移动 等逻辑的虚函数,MPxContextCommand 则是用来读取 MPxContext 数据的 Mel 命令。
  通过 MPxContextCommand 就可以用 Mel 命令来修改 MPxContext 的变量(比如笔刷大小之类的)

MPxContextCommand 官方文档

Maya C++ MPxToolCommand

MPxToolCommand 官方文档

  上面提到的方案 Context 进行处理的时候是没有 Undo 功能的。
  因此 Maya C++ 提供了 MPxToolCommand 这样将需要 undo 的逻辑放到 Command 当中实现,就可以 undo redo 操作了。
  上面文档的案例来自于 devkit\plug-ins\helixTool\helixTool.cpp

image
maya2020 - helixTool.mll

  这里照样提供 Maya2020 windows 版本的 mll 插件
  Maya 加载 mll 插件

1
2
3
import maya.cmds as cmds
ctx = cmds.helixToolContext()
cmds.setToolTo(ctx)

  加载mll 插件后,可以使用上面的代码激活 Context

image

  需要注意这个插件只能在旧的 Viewport 生效 (我测了好久才明白过来)

  这个工具可以在 Maya Viewport 拖拽一个 圆柱预览 ,这个圆柱最后生成 螺旋线。
  通过 MPxToolCommand 的方式就可以让生成的 螺旋线 支持undo。


  为何这个工具不能在 viewport2.0 下使用

image

  从上面的 API 列表可以看到 doDrag doPress 好几个 API 都有两个实现。
  一个是只传入 event 的,这个方法只在 老 Viewport 下调用。
  Viewport2.0 调用的是传入 MUIDrawManager 的方法。
  helixTool 没有实现 MUIDrawManager 的方法,所以 Viewport2.0 下不起作用。

Tool Contexts 官方文档

  官方文档被打散到 Viewport2.0 的目录下了,具体的说法可以参照上面

笔刷工具的 UI

Tool property sheets 官方文档

<>Properties.mel 实现左侧的可修改界面
<>Values.mel 获取笔刷数值 (更新到界面上)

  Context 激活之后,双击可以看到工具界面

image

  这个界面就是遵循上面两个 mel 的方法来实现的。


image

  可以继续参考 helixTool 的源码目录,它提供了 helixProperties.melhelixValues.mel 脚本
  那么上面的命名 <> 是怎么决定的,为啥用 helixProperties 而不是 helixToolProperties

image

  其实这是 getClassName 决定的。
  mel脚本并不是重点,双击 Context 调用的是 helixProperties helixValues 两个 mel 方法,如果找不到才会找同名脚本。

用 Python 生成 Mel Proc

  如果要编写自定义的 UI,一定要用 mel 才能编写吗?
  能否用 Python 解决问题呢?

Python function as a MEL procedure 官方文档

image

  如果嫌弃使用 mel 确实可以参考上面的链接用 Python 创建的 Mel Proc

C:\Program Files\Autodesk\Maya2020\Python\Lib\site-packages\maya\mel\melutils.py
具体的代码实现可以通过上面的路径找到。

image

  我尝试了一下,默认 returnCmd 是 False 会打开文件窗口生成出 mel 脚本。
  可以设置 returnCmd=True 这样就返回 mel 代码了。
  后面可以用 mel.eval 来执行返回的代码

  就是传入的Python function 如果不在 Python 模块之下会弹出警告


py2melProc 文档

  pymel 库也提供了 py2mel 的方法
  使用这个方法会比 Maya 内置的处理好一些
  实现的原理基本一致,都是通过 Python 构建出 Mel 代码,
  Mel 代码本质就是用 python 关键字执行 Python 代码 (一会 Python 一会 Mel 的似乎挺绕的(:з」∠))

  pymel 还提供了 mel2pyStr 的方法可以直接将 mel 代码转成 Python 的版本。
  这样就可以避免 python 和 mel 混写。

1
2
3
4
5
6
from pymel.tools import mel2py
path = r"C:\Program Files\Autodesk\Maya2018\scripts\others\customtoolPaint.mel"
with open(path,'r') as rf:
content = rf.read()
py_str = mel2py.mel2pyStr(content, pymelNamespace="pm")
print(py_str)

  比如上面就可以将一些内置的 mel 案例转换成 python 版本。
  pymelNamespace 可以给所有的调用加上相应的前缀。

  利用上面的方法就可以将 helixTool 的 mel 脚本转为 Python 实现

image

  转换完成之后需要注意 function 调用,要将 pm.mel 去掉
  因为之前 proc 编程 Python function 用 pm.mel.helixSetCallbacks 是调用不了的。

image

  另外一些变量名 mel 里面可能命名为了 set ,如果这些是 Python 的关键字或者内置命名需要注意。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
import pymel.core as pm
from pymel.tools import py2mel

def helixProperties():
pm.setUITemplate("DefaultTemplate", pushTemplate=1)
parent = str(pm.toolPropertyWindow(q=1, location=1))
pm.setParent(parent)
pm.columnLayout("helix")
pm.tabLayout("helixTabs", childResizable=True)
pm.columnLayout("helixTab")
pm.frameLayout("helixFrame", cll=True, l="Helix Options", cl=False)
pm.columnLayout("helixOptions")
pm.separator(style="none")
pm.intSliderGrp(
"numCVs", field=1, minValue=20, maxValue=100, value=1, label="Number of CVs"
)
pm.checkBoxGrp("upsideDownGrp", numberOfCheckBoxes=1, l1=" ", label="Upside Down")
pm.setParent("..")
# helixOptions
pm.setParent("..")
# helixFrame
pm.setParent("..")
# helixTab
pm.setParent("..")
# helixTabs
pm.setParent("..")
# helix
# Name the tabs; -tl does not allow tab labelling upon creation
pm.tabLayout("helixTabs", tl=("helixTab", "Tool Defaults"), e=1)
pm.setUITemplate(popTemplate=1)
helixSetCallbacks(parent)

def helixSetCallbacks(parent):
pm.setParent(parent)
pm.checkBoxGrp(
"upsideDownGrp",
e=1,
on1=lambda *args: pm.helixToolContext(pm.currentCtx(), upsideDown=True, e=1),
of1=lambda *args: pm.helixToolContext(pm.currentCtx(), upsideDown=False, e=1),
)
pm.intSliderGrp(
"numCVs",
e=1,
cc=lambda *args: pm.helixToolContext(pm.currentCtx(), numCVs=args[0], e=1),
)

def helixValues(toolName):
parent=(str(pm.toolPropertyWindow(q=1, location=1)) + "|helix|helixTabs|helixTab")
pm.setParent(parent)
icon="helixTool.xpm"
help=""
pm.mel.toolPropertySetCommon(toolName, icon, help)
pm.frameLayout('helixFrame', en=True, e=1, cl=False)
helixOptionValues(toolName)
pm.mel.toolPropertySelect('helix')

def helixOptionValues(toolName):

cv_num = 0
cv_num=int(pm.mel.eval("helixToolContext -q -numCVs " + toolName))
pm.intSliderGrp('numCVs', e=1, value=cv_num)
cv_num=int(pm.mel.eval("helixToolContext -q -upsideDown " + toolName))
if cv_num:
pm.checkBoxGrp('upsideDownGrp', e=1, value1=1)
else:
pm.checkBoxGrp('upsideDownGrp', e=1, value1=0)

py2mel.py2melProc(helixProperties, procName="helixProperties")
py2mel.py2melProc(helixValues, procName="helixValues")

  经过一些修改之后,可以实现用 Python 的方式来编写 Mel Proc。
  只是还是需要熟悉一下 mel UI 构建的语法。

Maya C++ CurveBrush

  通过上面一番探讨之后,我们理清楚了做一个笔刷需要什么。

image

  所以我在 C++ 代码层面拆分三个头文件,分别对应 MPxContext MPxContextCommand MPxContextToolCommand 的实现。
  如何开发也可以参考 helixTool 的代码。

image

  注册插件的时候需要同时注册 MPxContextCommandMPxContextToolCommand
  这样 Maya 就知道这两个命令是关联在一起的, MPxContext 里面调用 newToolCommand 方法就可以获取到 MPxContextToolCommand

笔刷属性调整

  我先要让笔刷按住 B 键的时候可以实现 大小 调整。
  默认 Maya API 没有提供键盘事件的监听。
  于是查找官方的案例,找到了 devkit\plug-ins\grabUVMain.cpp

maya2020 - grabUV.mll

  这里提供 Maya2020 windows 版本的 mll 插件
  Maya 加载 mll 插件

1
2
3
import maya.cmds as cmds
ctx = cmds.grabUVContext()
cmds.setToolTo(ctx)

  这个插件可以按住 B 键调整笔刷的大小。
  原理是利用 Qt 的 eventFilter 监听全局的键盘响应,所以编译的 include 路径需要有 Qt 的头文件,默认的 include 路径只有 Qt 头文件压缩包,需要解压缩来索引。

  所以我也是用同样的方式监听是否有按 B 键。
  左键拖拽调整笔刷大小,中键拖拽调整笔刷强度。

曲线衰变颜色

image

  笔刷覆盖的范围呈现颜色,这个是用 Viewport2.0MUIDrawManager 实现的。

image
MUIDrawManager

  MUIDrawManager 提供了 mesh 的 API 进行曲线模型等的绘制。
  最重要的第一点是可以传入颜色数组,根据每个点自定义颜色,其他的 line API 无法实现这个功能

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
MStatus curveBrushContext::doPtrMoved(MEvent &event, MHWRender::MUIDrawManager &drawMgr, const MHWRender::MFrameContext &context)
{
short x, y;
event.getPosition(x, y);
mBrushCenterScreenPoint = MPoint(x, y);
auto radius = mBrushConfig.size();

drawMgr.beginDrawable();
if (bFalloffMode)
{
for (unsigned int index = 0; index < objDagPathArray.length(); ++index)
{
MPointArray pointArray;
MColorArray colorArray;
MFnNurbsCurve curveFn(objDagPathArray[index]);
unsigned int segmentCount = 100;
for (unsigned int pointIndex = 0; pointIndex < segmentCount; ++pointIndex)
{
MPoint point;
auto param = curveFn.findParamFromLength(curveFn.length() * pointIndex / segmentCount);
curveFn.getPointAtParam(param, point, MSpace::kWorld);
pointArray.append(point);

// NOTE(timmyliang): draw falloff
short x_pos, y_pos;
view.worldToView(point, x_pos, y_pos);
MPoint screenPoint(x_pos, y_pos);
auto distance = (mBrushCenterScreenPoint - screenPoint).length();
auto field = 1 - distance / radius;
// NOTE(timmyliang): transparent
colorArray.append(distance > radius ? MColor(0.f) : MColor(field, field, field));
}

drawMgr.setLineWidth(12.0f);
drawMgr.mesh(MHWRender::MUIDrawManager::kLineStrip, pointArray, NULL, &colorArray);
}
}

drawMgr.setColor(MColor(1.f, 1.f, 1.f));
drawMgr.setLineWidth(2.0f);
drawMgr.circle2d(mBrushCenterScreenPoint, radius);

drawMgr.endDrawable();
return MS::kSuccess;
}

  那么问题就变成怎么获取顶点上色了,如果曲线的顶点数量很少就很难有好的显示效果。

  因此这里使用 findParamFromLength getPointAtParam 的方式重新采样曲线的顶点。
  对采样的顶点再判断一下是否在笔刷的圆圈范围内,范围外的附上透明的颜色,范围内的根据距离附上黑白色。

曲线 CV 移动

  首先要获取 drag 偏移的向量。
  通过 doPress 方法可以获取到点击的时候的向量偏移。
  再通过 doDrag 获取拖拽的时候鼠标的位置。
  两个位置坐标就可以得到偏移的向量。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
MStatus curveBrushContext::doPress(MEvent &event, MHWRender::MUIDrawManager &drawMgr, const MHWRender::MFrameContext &context)
{
view = M3dView::active3dView();
event.getPosition(startPosX, startPosY);
fStartBrushSize = mBrushConfig.size();
fStartBrushStrength = mBrushConfig.strength();

return MS::kSuccess;
}

MStatus curveBrushContext::doDrag(MEvent &event, MHWRender::MUIDrawManager &drawMgr, const MHWRender::MFrameContext &context)
{
view.refresh(false, true);
short currentPosX, currentPosY;
event.getPosition(currentPosX, currentPosY);
auto currentPos = MPoint(currentPosX, currentPosY);

MPoint start(startPosX, startPosY);
MVector delta = MVector(currentPos - start);

drawMgr.beginDrawable();
drawMgr.setColor(MColor(1.f, 1.f, 1.f));
drawMgr.setLineWidth(2.0f);
// NOTE(timmyliang): hold down `B` key
if (eDragMode == kBrushSize)
{
float deltaValue;
char info[64];
// NOTES(timmyliang): left mouse for size
if (event.mouseButton() == MEvent::kLeftMouse)
{
deltaValue = delta.x > 0 ? delta.length() : -delta.length();
mBrushConfig.setSize(fStartBrushSize + deltaValue);
sprintf(info, "Brush Size: %.2f", mBrushConfig.size());
drawMgr.text2d(currentPos, info);
}
// NOTES(timmyliang): middle mouse for strength
else if (event.mouseButton() == MEvent::kMiddleMouse)
{
deltaValue = delta.y > 0 ? delta.length() : -delta.length();
mBrushConfig.setStrength(fStartBrushStrength + deltaValue);
sprintf(info, "Brush Strength: %.2f", mBrushConfig.strength());
drawMgr.text2d(currentPos, info);
}
drawMgr.line2d(start, MPoint(startPosX, startPosY + mBrushConfig.strength() * 2));
}
else
{
MPoint startNearPos, startFarPos, currNearPos, currFarPos;
view.viewToWorld(currentPosX, currentPosY, currNearPos, currFarPos);
view.viewToWorld(startPosX, startPosY, startFarPos, startFarPos);
// NOTE(timmyliang): use tool command for undo
curveBrushTool *cmd = (curveBrushTool *)newToolCommand();
cmd->setStrength(mBrushConfig.strength());
cmd->setRadius(mBrushConfig.size());
cmd->setMoveVector((currFarPos - startFarPos).normal());
cmd->setStartPoint(start);
cmd->setDagPathArray(objDagPathArray);
cmd->redoIt();
cmd->finalize();
}

drawMgr.circle2d(start, mBrushConfig.size());
drawMgr.endDrawable();
return MS::kSuccess;
}

  doDrag 还会判断是否按住 B 键,按住的话就调整笔刷的大小。
  反之则调用 newToolCommand 执行 CV 移动的逻辑


  ToolCommand 会获取曲线上 CV 点的位置,将空间坐标转为屏幕坐标。
  这样可以判断这些 CV 点是否在笔刷范围内。
  如果在范围的 CV 点根据笔刷提供的方向进行偏移。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
MStatus curveBrushTool::redoIt()
{

MVector offsetVector = moveVector * 0.002 * strength;
M3dView view = M3dView::active3dView();

// NOTE(timmyliang): move curves cv in radius
short x_pos, y_pos;
for (unsigned int index = 0; index < dagPathArray.length(); ++index)
{
MFnNurbsCurve curveFn(dagPathArray[index]);
std::map<int, MVector> offsetMap;
for (MItCurveCV cvIter(dagPathArray[index]); !cvIter.isDone(); cvIter.next())
{
MPoint pos = cvIter.position(MSpace::kWorld);
int cvIndex = cvIter.index();
curvePointMap[index][cvIndex] = pos;
view.worldToView(pos, x_pos, y_pos);
if ((startPoint - MPoint(x_pos, y_pos)).length() < radius)
{
offsetMap[cvIndex] = pos + offsetVector;
}
}
for (const auto &it : offsetMap)
{
curveFn.setCV(it.first, it.second, MSpace::kWorld);
}
curveFn.updateCurve();
}
return MStatus::kSuccess;
}

  通过 MItCurveCV 遍历曲线上所有的 CV 点。
  利用 setCV 方法可以实现顶点的偏移
  C++ 这边我发现不能在 MItCurveCV 的遍历过程中调用 setCV ,它会导致遍历中断。
  但是用 MItCurveCV 提供的 setCVPosition 无法实现位置的刷新。
  最后只好将 CV序号 和 位置通过 Map 保存起来,通过 setCV API 去偏移。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
MStatus curveBrushTool::undoIt()
{
// NOTE(timmyliang): reset point position
for (const auto &kv : curvePointMap)
{
MFnNurbsCurve curveFn(dagPathArray[kv.first]);
for (const auto &it : kv.second)
{
int cvIndex = it.first;
MPoint pos = it.second;
curveFn.setCV(cvIndex, pos, MSpace::kWorld);
}
}

return MStatus::kSuccess;
}

  通过 curvePointMap 变量保存了上一次所有 CV 点的位置,undo 只要遍历这个字典去重置 CV 位置即可。

OpenMaya 2.0 笔刷开发

  既然 C++ 可以开发出如上看到的笔刷,理论上也可以通过 Python OpenMaya 库进行笔刷开发。
  但是我发现 OpenMaya 1.0 不支持 Viewport 2.0 的 API,比如上面关键的 MUIDrawManager
  在 OpenMaya1.0 下是不不存在的。

1
2
3
4
5
from maya import OpenMayaRender
OpenMayaRender.MUIDrawManager
# Error: AttributeError: file <maya console> line 2: 'module' object has no attribute 'MUIDrawManager' #
from maya.api import OpenMayaRender
OpenMayaRender.MUIDrawManager

  可以看到 OpenMaya 2.0 才有 MUIDrawManager

https://matiascodesal.com/blog/maya-python-api-20-it-ready-yet/

  以前 18 年的时候还看到有人了文章介绍 OpenMaya 2.0 到底是否可以已经完善了。
  OpenMaya 2.0 与 OpenMaya 1.0 相比还缺了挺多的 C++ 类的。
  而且 OpenMaya2.0 的案例都有一些代码错误,比如 plug-ins\python\api2\py2LassoTool.py (已经是 2023 的最新版本了)
  这实在是令人失望,脚本的第 224 行有明显 true 使用不当,并且 MItCurveCV 这个类 OpenMaya 2.0 不支持的。
  我启用这个脚本框选 CV 点直接给我报错(:з」∠)
  也因为 OpenMaya 2.0 各种不完善, 👨‍💻mottosso 大佬才会做自己的 Pyd wrapper 封装 C++ API cmdc ,只是目前的进度还需要更多人加入支持开发。


  那是用 OpenMaya 2.0 能否完成我上面的 C++ 曲线笔刷的复刻呢?
  我查了一下,发现 Maya 2020 之后添加了 MPxToolCommand 命令,似乎可以实现和 C++ 一样的 undo 命令。
  然而我的实测却让我非常失望。

https://github.com/FXTD-ODYSSEY/Maya-CurveBrush/blob/main/plug-ins/om2_curve_brush.py

MPxContextCommand 缺失 syntax parser 方法

  基于 OpenMaya 2.0 版本的插件我已经写完了,只是被它的不完整气得不轻。
  首先 MPxContextCommand 缺失了 syntax parser 方法
  即便提供了 doQueryFlags doEditFlags 的 API 但是没法和 C++ 一样进行调用,但是 OpenMaya 1.0 提供了 _syntax _parser 方法给 Python 调用。

registerContextCommand 不支持 MPxToolCommand 注册

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
def initializePlugin(plugin):
pluginFn = om.MFnPlugin(plugin)
try:
pluginFn.registerContextCommand(CONTEXT_NAME, CurveBrushContextCmd.creator)
# TODO(timmyliang): not support MPxToolCommand registered
# pluginFn.registerContextCommand(
# CONTEXT_NAME,
# CurveBrushContextCmd.creator,
# CONTEXT_TOOL_NAME,
# CurveBrushTool.creator,
# CurveBrushTool.newSyntax,
# )
except:
sys.stderr.write("Failed to register command: %s\n" % CONTEXT_NAME)
raise

  OpenMaya 2.0 终于在 Maya 2020 提供了 MPxToolCommand 的接口。
  但是 MPxToolCommand 需要通过 registerContextCommand 来注册进去。
  但是它目前不支持 5 个参数的调用,导致 MPxToolCommand 无法注册。

1
# Error: TypeError: file F:/repo/CMakeMaya/modules/Maya-CurveBrush/plug-ins/om2_curve_brush.py line 448: function takes exactly 2 arguments (5 given) # 

  注册的时候会提示 registerContextCommand 只接受两个参数。

MPxToolCommand doFinalize 无法传入参数

1
2
3
4
5
6
7
8
9
10
class CurveBrushTool(omui.MPxToolCommand):
def finalize(self):
command = om.MArgList()
command.addArg(self.commandString)
for flag, config in self.flags_data.items():
long_flag = config.get("long")
command.addArg(flag)
command.addArg(getattr(self, long_flag[1:]))
# TODO(timmyliang): not accept the command argument
# return self.doFinalize(command)

  虽然 registerContextCommand 无法注册 MPxToolCommand 导致 newToolCommand 没有正常的返回。
  但我可以单独实例化 MPxToolCommand 从而实现 undo
  可是还是不行,而且这个坑爹的情况明显是官方的问题。
  doFinalize 明明可以接受一个 MArgList 类型的参数,但是这个 Python 函数却不接受任何参数(:з」∠)

OpenMaya 2.0 展示

  虽然 2.0 有上述的诸多问题,笔刷的基础功能还是可以实现的。
  只是 undo 功能解决不了,倒是可以将曲线的 tweak 操作转移到另一个 Command 上从而实现 undo 的。
  不过我这里就点到为止,主要踩了 OpenMaya 2.0 的坑,对它好感度降低了不少(:з」∠)

Python Qt Overlay 实现自定义绘制

  上面提到了 OpenMaya 1.0 缺失了 MUIDrawManager 所以无法在 Viewport 2.0 下进行图像绘制。

image

  C++ 文档也注明了带 MUIDrawManager 是无法在 Python 下使用的。
  我也测试了不传入 MUIDrawManager 的几个方法,他们只能在 Legacy Viewport 下响应触发。

  那还有什么方法不用 C++ 也可以实现 Python 的绘制呢?
  这就可以参考一个非常棒的 Maya Python 工具 spore

  spore 也实现了自己的笔刷工具,并且对低版本 Maya 兼容。
  它的做法不是通过 Maya API 实现,而是利用 Qt 的 API 进行绘制。
  首先对 Maya 的 Viewport 叠加一层透明的 QWidget 层,通过 paintEvent 的实现,绘制自定义图形叠加到 Viewport 上。

  实现效果如上图,基本和 Maya API 的绘制效果很接近。

Overlay 组件实现

  组件叠加的方案我之前的文章也有过 Unreal Python 路径定位启动器
  核心思路就是取消 Widget 的边框,忽略输入影响,透明化背景并且永远保持在最前面。

1
2
3
4
5
6
7
8
9
10
11
12
class CanvasOverlay(QtWidgets.QWidget):
def __init__(self, context):
# type: (CurveBrushContext) -> None
super(CanvasOverlay, self).__init__()
self.setWindowFlags(
QtCore.Qt.FramelessWindowHint
| QtCore.Qt.SplashScreen
| QtCore.Qt.WindowStaysOnTopHint
| QtCore.Qt.WindowTransparentForInput
)
self.setAttribute(QtCore.Qt.WA_TranslucentBackground)
self.setAttribute(QtCore.Qt.WA_NoSystemBackground)

  这样就是一个无边框透明的窗口,如果不加上颜色用户是无感知的。

spore 参考

多个 Viewport 叠加支持


注: 这里的 Overlay 加上了大色块方便观察。

>   我添加了多个 Viewport 的 Overlay 支持,spore 默认是只对笔刷激活时的 Viewport 进行 Overlay 操作。
>   如果切换到多视图或者单独的 Viewport 窗口就会让 Overlay 显示不正常。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
class AppFilter(QtCore.QObject):
def __init__(self, canvas):
# type: (CurveBrushContext) -> None
super(AppFilter, self).__init__()
self.canvas = canvas

def eventFilter(self, receiver, event):
if event.type() == QtCore.QEvent.MouseButtonPress:
widget = QtWidgets.QApplication.widgetAt(QtGui.QCursor.pos())
panel = isinstance(widget, QtCore.QObject) and widget.parent()
name = panel and panel.objectName()
if name:
is_model_editor = cmds.objectTypeUI(name, i="modelEditor")
self.canvas.setVisible(is_model_editor)
if is_model_editor:
QtCore.QTimer.singleShot(0, self.canvas.setup_active_viewport)
return super(AppFilter, self).eventFilter(receiver, event)

class CurveBrushContext(OpenMayaMPx.MPxContext):
# 省略 ...
def toolOnSetup(self):
self.canvas = CanvasOverlay(self)
# NOTES(timmyliang): 获取 QApplication 进行监听
app = QtWidgets.QApplication.instance()
app_filter = AppFilter(self.canvas)
app.installEventFilter(app_filter)


>   我这里的做法是利用 toolOnSetup API ,激活笔刷的时候监听 Maya QApplication 全局的点击事件
>   如果点击的 Widget 是 modelEditor 就将 overlay 同步过去。
>   Qt 的 objectName 就是 Maya 的 UI control Name ,所以从 objectName() 获取的 API 可以直接用 objectTypeUI 判断类型
>   利用这个方法任何 Viewport 点击都可以直接 resize Overlay 上去。

>   本来不想搞得那么复杂的,但是 Maya 原生的监听方案不起作用 stackoverflow
>   stackoverflow 的回答是使用 timer 定时触发,也不是很理想,所以还是借助 Qt API 监听鼠标按键的方法最好。

### 监听 Viewport 事件

>   正如上面所说的 doDrag doPress 等一系列 API 在 Viewport 2.0 下是失效的。
>   通过 Qt 的 installEventFilter 可以实现对 Viewport 的事件监听。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import shiboken2
import maya.OpenMaya as om
import maya.OpenMayaUI as omui

from PySide2.QtWidgets import QWidget
from PySide2.QtCore import QObject

def active_view():
""" return the active 3d view """
return omui.M3dView.active3dView()

def active_view_wdg():
""" return the active 3d view wrapped in a QWidget """
view = active_view()
active_view_widget = shiboken2.wrapInstance(long(view.widget()), QWidget)
return active_view_widget


spore 参考

>   通过 OpenMaya 1.0 的 API 可以获取当前激活的 Viewport QWidget
>   拦截这个 Viewport QWidget 的事件可以实现鼠标点击拖拽等等的响应。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41

class MouseFilter(QtCore.QObject):
wheel = QtCore.Signal(QtCore.QEvent)
moved = QtCore.Signal(QtCore.QEvent)
clicked = QtCore.Signal(QtCore.QEvent)
dragged = QtCore.Signal(QtCore.QEvent)
released = QtCore.Signal(QtCore.QEvent)
entered = QtCore.Signal()
leaved = QtCore.Signal()

def __init__(self, *args, **kwargs):
super(MouseFilter, self).__init__(*args, **kwargs)
self.is_clicked = False

def eventFilter(self, receiver, event):
event_type = event.type()
if event_type == QtCore.QEvent.MouseMove:
self.moved.emit(event)
if self.is_clicked:
self.dragged.emit(event)
elif (
event_type == QtCore.QEvent.MouseButtonPress
or event_type == QtCore.QEvent.MouseButtonDblClick
):
self.is_clicked = True
self.clicked.emit(event)
elif event_type == QtCore.QEvent.MouseButtonRelease:
self.is_clicked = False
self.released.emit(event)
elif event_type == QtCore.QEvent.Enter:
self.entered.emit()
elif event_type == QtCore.QEvent.Leave:
self.leaved.emit()
elif event_type == QtCore.QEvent.Wheel:
self.wheel.emit(event)

return super(MouseFilter, self).eventFilter(receiver, event)

viewport = active_view_wdg()
mouse_filter = MouseFilter()
viewport.installEventFilter(mouse_filter)


>   通过上面的方式就可以拦截 viewport 的 event 通过 MouseFilter 的信号槽做相应的触发。

### 绘制实现

image

>   参考上图可以看到,Qt API 基本上和 Maya API 绘制的效果差不多。
>   Maya API 的 MUIDrawManager 提供了 mesh API 来绘制复杂图形。
>   Qt API 并没有类似的方法,不过 Qt 也有 QGradient
>   通过 QLinearGradient 可以实现上面的效果。
>   同样地需要对曲线进行二次采样,提高分段数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
def paintEvent(self, event):
self.draw_shape(self.create_brush_cricle(), QtCore.Qt.white, 2)
if self.is_press_B:
self.draw_shape(self.create_brush_line(), QtCore.Qt.white, 2)
self.draw_text(self._message_info)
for curve, data in self.color_data.items():
self.draw_shape(data.get("points"), data.get("colors"), 10)

return super(CanvasOverlay, self).paintEvent(event)

def create_brush_cricle(self, count=60):
shape = []
radius = self.radius
pt = self.start_pos if self.is_press_B else self.current_pos
for index in range(count + 1):
theta = math.radians(360 * index / count)
pos_x = pt.x() + radius * math.cos(theta)
pos_y = pt.y() + radius * math.sin(theta)
shape.append(QtCore.QPointF(pos_x, pos_y))
return shape

def create_brush_line(self):
shape = []
start_pt = self.start_pos if self.is_press_B else self.current_pos
shape.append(start_pt)
shape.append(QtCore.QPoint(start_pt.x(), start_pt.y() - self.strength))
return shape

def draw_shape(self, line_shapes, colors, width=1):
if len(line_shapes) < 2:
return
colors = colors or QtCore.Qt.white
painter = QtGui.QPainter(self)

painter.setRenderHint(painter.Antialiasing)
painter.begin(self)

if (
isinstance(colors, Iterable)
and not isinstance(colors, six.string_types)
and len(colors) == len(line_shapes)
):
# NOTES(timmyliang): paint falloff
for index, point in enumerate(line_shapes[:-1]):
start_point = point
end_point = line_shapes[index + 1]
grandient_color = QtGui.QLinearGradient(start_point, end_point)
start_color = colors[index]
end_color = colors[index + 1]
grandient_color.setColorAt(0, start_color)
grandient_color.setColorAt(1, end_color)
pen = QtGui.QPen(grandient_color, width)
pen.setCapStyle(QtCore.Qt.RoundCap)
pen.setJoinStyle(QtCore.Qt.RoundJoin)
painter.setPen(pen)
painter.drawLine(start_point, end_point)
else:
path = QtGui.QPainterPath()
path.moveTo(line_shapes[0])
[path.lineTo(point) for point in line_shapes]
color = QtGui.QColor(colors)
pen = QtGui.QPen(color, width)
painter.setPen(pen)
painter.drawPath(path)

painter.end()

def draw_text(self, text, pos=None, color=QtCore.Qt.white, width=1):
if not text:
return
painter = QtGui.QPainter(self)
pen = QtGui.QPen(color, width)
painter.setPen(pen)
pos = pos or self.current_pos + QtCore.QPoint(10, 0)
painter.drawText(pos, text)
painter.end()


>   上面是绘制用到的 一些 API
>   核心就是 draw_shape 里面如果传入了多个 color ,获取color每个顶点画一条渐变的线
>   多条线组合成圆形,由此有了衰变颜色的圆形曲线。

>   其他的绘制比如 绘制文字,Qt 有 drawText API
>   绘制圆圈可以利用 sin cos 数学函数来生成圆形的顶点进行绘制。


### 踩坑注意

>   QtCore.QPointOpenMaya.MPoint 两者的 Y 轴坐标起始不一样,所以通过 M3dView 将世界坐标转换为屏幕坐标的时候需要额外的处理。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
def world_to_view(position, invertY=True):
"""
convert the given 3d position to 2d viewport coordinates
"""
view = OpenMayaUI.M3dView.active3dView()
arg_x = OpenMaya.MScriptUtil(0)
arg_y = OpenMaya.MScriptUtil(0)

arg_x_ptr = arg_x.asShortPtr()
arg_y_ptr = arg_y.asShortPtr()
view.worldToView(position, arg_x_ptr, arg_y_ptr)
x_pos = arg_x.getShort(arg_x_ptr)
y_pos = arg_y.getShort(arg_y_ptr)

if invertY:
y_pos = view.portHeight() - y_pos

return (x_pos, y_pos)


spore 参考

## 基于 draggerContext 笔刷

Curve paint and tweak tool


  最后在 highend3d 里面也找到了一个直接 tweak CV 点的方案。
  这个方案采用 draggerContext 实现
  draggerContext 的案例就可以实现在 viewport 拖拽的时候实现回调。
  highend3d 的 ysd 曲线工具集还结合软选择的范围作为笔刷移动的范围参数,这是非常聪明的做法。
  也可以通过这个方式实现拖拽生成一条曲线。
  结合 OpenMaya API 可以做更多的事情,比如散布物体等等,用这个的方案比起 从零构建一个 MPxContext 要简单许多。

  绘制功能还是无法通过 draggerContext 解决,不过可以用上面 Qt Overlay 方案来解决。

ysv 工具优化

  从 highend3d 下载的 ysv 曲线工具可以用,但是有几个问题

  1. 直接调用 PySide 导致不兼容
  2. 没有做 Python3 兼容
  3. 部分代码在新版本代码下运行有 BUG

PySide 兼容

  将 PySide 的导入转成 Qt.py 的导入
  Qt.py 库的引入则是采用 submodule 的方式放到 scripts 目录下。

Python3 兼容

  Python3 兼容使用 Python内置的 lib2to3 库进行转换 参考链接

1
mayapy -m lib2to3 -w F:\repo\Maya-CurveBrush\scripts\ysv\ysvView.py

  通过这个方式就可以自动将所有的 print 括号加上等操作。省去繁琐的人工操作。
  我当时是写了一个脚本,批量执行,执行完之后调用 black 和 isort 风格化代码。

  生成完之后会将之前的文件加上 .bak 后缀,如果没有问题就可以把 bak 删除。

BUG 修复

  原代码获取当前摄像机是通过下面的方式

1
2
from pymel.core import *
modelEditor(getPanel(wf=1), e=1, nurbsCurves=1)

  但是如果当前 focus 的 panel 不是 modelEditor 就遭殃了。

1
2
3
4
5
from pymel.core import *
for mp in getPanel(type="modelPanel"):
if modelEditor(mp, q=1, av=1):
modelEditor(mp, e=1, nurbsCurves=1)
break

  所以我把代码改成上面的效果。


1
2
3
4
5
6
7
for crv in self.inViewCurves:
for cv in [crv.cv[0], crv.cv[-1]]:
cv = str(cv) # fix add here
setAttr(cv + ".xv", lock=1)
setAttr(cv + ".yv", lock=1)
setAttr(cv + ".zv", lock=1)

  用 pymel 获取 cv 点之后,直接将 NurbsCurveCV 与字符串相加
  但是 NurbsCurveCV 有自己的相加逻辑,所以这里需要加上字符串转换可以修复 BUG。

artisan 笔刷

  Maya 根据贴图在模型表面散列物体 以前也写过散列物体的文章,不过实现方式是非笔刷的。
  利用 artisan 就可以实现笔刷的方式散布物体了。

官方文档: Overview of MEL script painting

  官方提到有 spherePaint geometryPaint emitterPaint 几个案例。
  具体的代码可以在 mel 脚本库里面找到 eg: C:\Program Files\Autodesk\Maya2018\scripts\others\spherePaint.mel
  从最简单的 spherePaint 介绍

image

  在 Modify 页面下找到 Paint Scripts Tool 工具
  打开工具属性面板,在 Setup 标签页的 Tool setup cmd 输入 spherePaint 就可以激活笔刷

  激活工具之后就可以模型上刷 Sphere


  只可惜 artisan 笔刷它不响应 NurbsCurve ,只支持 mesh。
  所以无法实现上面探讨的 曲线笔刷 的功能。
  artisan 方案更适合颜色绘制或者是物体散布。

总结

  以上就是我发现的 Maya 笔刷的多种使用姿势。
  后面有机会可以再探讨一下 artisan 笔刷的关于顶点色编辑相关的内容。