前言

  上次我们讨论了怎么在 Maya 实现各种笔刷的姿势 Maya CurveBrush 笔刷开发
  趁着最近比较有空,我又捡起了之前想要开发顶点颜色单通道笔刷,
  仓库早在 1 年前就创建了,但是并没有好好开发出来。

https://github.com/FXTD-ODYSSEY/Maya-VertexColorPainter

  关于单通道顶点色笔刷,其实是之前项目组给我提的需求,Maya 官方提供的 Paint Vertex Color Tool 挺好的

image

  就是绘制的时候顶点色是混合在一起的。无法实现分通道绘制。
  网上也可以找到有不少帖子抱怨 Maya 竟然没有实现这个功能的。

https://polycount.com/discussion/191918/single-channel-vertex-painting-in-maya-2018
https://www.reddit.com/r/Maya/comments/87znt2/paint_on_separate_channels_in_vertex_painting/

  我当时做了一些研究,后来因为太忙了,就将需求转交给其他同事负责了。
  那个同事解决了需求,只是解决方案比较复杂,需要用 OpenMaya 写一个节点,再加自定义笔刷实现。

  经过我上次笔刷的折腾,我在想能否扩展原本 Maya Paint Vertex Color Tool 的功能

  上面就是我最终实现的效果,在 Maya 的原生 UI 上进行修改,提供了额外的 UI 配置来进行单通道绘制。

笔刷选型

  Maya CurveBrush 笔刷开发 我这篇文章已经覆盖了写笔刷的各种姿势。
  用 Maya 开放的 MPxContext 写笔刷实最为自由的,但是很多功能都没有。
  使用 Maya 内置的 artisan 笔刷,则已经实现了好多功能。

  1. 自带镜像
  2. 笔刷可以自定义笔刷图章实现渐变
  3. 内置序列化功能

artisan painting 扩展官方文档

  所以如果不是复杂的笔刷,能用 artisan 就用 artisan 去实现。
  只可惜 Maya 没有暴露 artisan 笔刷的 C++ 接口,所以如果用 C++ 开发就只能重新实现一遍 artisan 的功能,比较麻烦。
  当然绘制顶点色我直接使用 artAttrPaintVertexCtx 即可。

实现原理

单通道 color set 拆分

  利用 Maya 提供的 ColorSet 功能,将模型的主顶点色分拆成四个通道的 ColorSet
  我这里就分别命名为 VertexColorR VertexColorG VertexColorB VertexColorA
  绘制的时候根据选择 UI 的选择激活相应的 ColorSet

  这一步可以用 artAttrPaintVertexCtxtoolOnProctoolOffProc 定义激活和关闭的回调。
  激活 context 的时候创建 ColorSet 拆分,退出 Context 的时候删除冗余的 ColorSet
  这个地方的 toolOnProc toolOffProc 同样只接受 mel 函数,用 Python 解决的方案参考 Maya CurveBrush 笔刷开发 这篇文章。

颜色分解

  那么上面拆分 ColorSet 的时候就需要将对应的 MainColorSet 的颜色按通道赋值给对应单通道的 ColorSet.

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
import pyeml.core as pm
from maya import OpenMaya
PAINT_CTX = "artAttrColorPerVertexContext"
color_set_representation = {
"R": "RGB",
"G": "RGB",
"B": "RGB",
"A": "A",
}
def get_color_sets(node):
color_sets = pm.polyColorSet(node, q=1, allColorSets=1)
return color_sets or pm.polyColorSet(node, create=1)

def filter_color(color, index, source_color=None):
if index > 3:
return color
is_color = isinstance(source_color, OpenMaya.MColor)
color_list = list(source_color) if is_color else [0, 0, 0, 1]
color_list[index] = color[index]
return OpenMaya.MColor(*color_list)

# NOTES(timmyliang): 获取当前正在绘制的节点
for node in set(pm.artAttrPaintVertexCtx(PAINT_CTX, q=1, pna=1).split()):
node = pm.PyNode(node)
node.displayColors.set(1)
color_sets = get_color_sets(node)
main_color_set = color_sets[0]

mesh = node.__apimfn__()

# NOTES(timmyliang): 获取主 color set 顶点色
color_array = OpenMaya.MColorArray()
mesh.getVertexColors(color_array, main_color_set)

# NOTES(timmyliang): 获取顶点序号数组
vtx_array = OpenMaya.MIntArray()
for array_index in range(color_array.length()):
vtx_array.append(array_index)

final_colors = OpenMaya.MColorArray()
for channel_index, color_channel in enumerate(cls.CHANNELS):
# NOTES(timmyliang): 如果通道 color set 不存在则创建
color_set = "VertexColor{0}".format(color_channel)
if color_set not in color_sets:
rpt = color_set_representation.get(color_channel)
pm.polyColorSet(node, create=1, rpt=rpt, colorSet=color_set)

mesh.setCurrentColorSetName(color_set)
final_colors.clear()
for array_index in range(color_array.length()):
full_color = color_array[array_index]
color = filter_color(full_color, index=channel_index)
final_colors.append(color)
# NOTES(timmyliang): 批量设置顶点色
mesh.setVertexColors(final_colors, vtx_array)
mesh.setCurrentColorSetName(main_color_set)

  这里利用 pymel 提供的 __apimfn__ 直接获取 MFnMesh 对象
  利用 setVertexColors API 批量设置顶点色,性能比起单点设置要好很多。

单通道 单颜色 绘制

  下一步就是要实现绘制将颜色锁在对应通道上。
  比如我在 UI 上设置为值绘制 R 通道的状态,绘制选择的颜色是 白色 [255,255,255],点击 Viewport 的时候会将颜色过滤成 红色 [255,0,0] ,这样勾选 R 的时候就只会刷出 红色 没有其他颜色。
  这里可以监听 Viewport 的 press 和 release 触发,当点击 viewport 的时候根据 UI 勾选的通道过滤 Ctx 颜色配置。

1
2
3
4
5
6
7
8
9
10
# NOTES(timmyliang): 获取 UI 的颜色和透明值
rgb = pm.colorSliderGrp("colorPerVertexColor", q=1, rgb=1)
alpha = pm.floatSliderGrp("colorPerVertexAlpha", q=1, value=1)
rgb.append(alpha)
# NOTES(timmyliang): 组装颜色,过滤掉相应的通道。
# 获取 ui 的选项
sel = pm.radioButtonGrp(SINGLE_CONTROL, q=1, sl=1)
# 过滤颜色
color = filter_color(rgb, index=sel)
pm.artAttrPaintVertexCtx(PAINT_CTX, e=1, cl4=tuple(color))

  release 的时候恢复之前的 顶点色 颜色配置。

release 通道颜色同步

  最后还需要实现将绘制完的通道同步到其他的 color set 上的功能。
  因此 release 触发的时候要判断当前绘制的模式,如果绘制 rgb 就将颜色分解到对应的单通道上。
  相反如果是单通道绘制就要将颜色反馈到 rgb 的主 color set 上。

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
def apply_color_channel(cls):
index = pm.radioButtonGrp(cls.SINGLE_CONTROL, q=1, sl=1)
mode = cls.OPTION_ITEMS[index + 1]
is_rgb = mode == "RGB"
for node in cls.get_paint_nodes():
dag_path = node.__apimdagpath__()
mesh = OpenMaya.MFnMesh(dag_path)
color_sets = cls.get_color_sets(node)
main_color_set = color_sets[0]

current_color_set = mesh.currentColorSetName()

main_colors = OpenMaya.MColorArray()
mesh.getVertexColors(main_colors, main_color_set)
vtx_array = cls.vertex_color_data[node.fullPathName()]
final_colors = OpenMaya.MColorArray()

# NOTES(timmyliang): 如果当前绘制为非单通道
if is_rgb:
# NOTES(timmyliang): 将当前的主颜色 拆分到各个通道上
for channel_index, color_channel in enumerate(cls.CHANNELS):
final_colors.clear()
color_set = "VertexColor{0}".format(color_channel)
mesh.setCurrentColorSetName(color_set)
for vtx_index in vtx_array:
main_color = main_colors[vtx_index]
color = cls.filter_color(main_color, channel_index)
final_colors.append(color)
mesh.setVertexColors(final_colors, vtx_array)
else:
mode_index = cls.OPTION_ITEMS.index(mode) - 2
channel_colors = OpenMaya.MColorArray()
fix_colors = OpenMaya.MColorArray()
color_set = "VertexColor{0}".format(mode)
mesh.getVertexColors(channel_colors, color_set)
# NOTES(timmyliang): 获取单通道的颜色 回馈到主颜色上
for vtx_index in vtx_array:
channel_color = channel_colors[vtx_index]
main_color = main_colors[vtx_index]
color = cls.filter_color(channel_color, mode_index, main_color)
final_colors.append(color)
fix_color = cls.filter_color(channel_color, mode_index)
fix_colors.append(fix_color)

mesh.setVertexColors(fix_colors, vtx_array)
mesh.setCurrentColorSetName(main_color_set)
mesh.setVertexColors(final_colors, vtx_array)

mesh.setCurrentColorSetName(current_color_set)

  通过上面的方式就可以每次绘制完之后同步顶点色到对应的 color set 上。

Maya UI 修改 & 扩展

image

  Maya 有个非常好的设计是 UI 使用过 mel 脚本组装的,这样不需要编译就可以改动 UI,而且这部分的 mel 脚本都是开源的。
  可以很清楚地知道 Maya 是如何组装出相应工具的界面。

C:\Program Files\Autodesk\Maya2018\scripts\others\artAttrColorPerVertexProperties.mel

  Maya 的颜色笔刷是通过上面路径的 mel 脚本实现的。
  这样可以找到对应 UI 的名字

image

  如上图所示,可以找到 artAttrColorChannelChoices 的名字。
  然后用 cmds 命令可以对这些 UI 进行二次修改。

1
2
from maya import cmds
cmds.radioButtonGrp('artAttrColorChannelChoices',e=1,gbc=[255,0,0])

image

  比如执行上面的代码可以修改相应 UI 的背景颜色。


  上面已经展示了如何修改原生的 UI
  这些操作需要学习 Mel 的 UI 构建方式,会有点复杂。

  不过 Mel 的 example 都有案例,比如这里的UI 使用了 columnLayout
  那我可以去到 columnLayout 的文档运行案例代码进行学习。

image

  将代码放到代码编辑器执行。

image

  查了一下 columnLayout 的 API ,发现它竟然没有 insert 功能。
  于是我找了一下 Mel Tips大全的网站 MEL How-To (上古网站,但对学习Mel很有帮助)

image

  可以找到一个 链接 如何实现UI的置顶插入。

image

  方案一使用 frameLayout 比较繁琐
  方案二则是使用一个新的 Layout 然后将旧 Layout 的 UI 删除掉。
  这个方法删除 UI 对我想要的效果并不适用。

  不过倒是启发了我,我想到了可以利用 childArray 可以拿到 Layout 下所有的 Control 名字。
  然后对每个 Control 修改 parent 到新的 Layout 上。

使用 cmds 嵌入 UI

1
2
3
4
5
6
7
8
9
10
from maya import cmds
parent = cmds.radioButtonGrp('artAttrColorChannelChoices',q=1,parent=1)
print(parent)
# ToolSettings|MainToolSettingsLayout|tabLayout1|artAttrColorPerVertex|artCommonOperationFrame|columnLayout1061|columnLayout1065
window = cmds.window()
column_layout = cmds.columnLayout()
for control in cmds.layout(parent,q=1,childArray=1):
cmds.control(control, e=1, p=column_layout)

cmds.showWindow(window)

  上面的想法写成代码如上所示

image

  直接实现了 UI 的乾坤大挪移
  只是显示上有些不一样,主要原因是 mel 构建 UI 的时候使用 setUITemplate

1
2
3
4
5
6
7
8
9
10
11
from maya import cmds
window = cmds.window()
cmds.setUITemplate("OptionsTemplate", pushTemplate=1)

column_layout = cmds.columnLayout()
parent = cmds.radioButtonGrp('artAttrColorChannelChoices',q=1,parent=1)
for control in cmds.layout(parent,q=1,childArray=1):
cmds.control(control, e=1, p=column_layout)

cmds.setUITemplate(popTemplate=1)
cmds.showWindow(window)

image

  加上了 OptionsTemplate 之后 UI 的显示就保持一致了
  所以在 parent control 的过程中加入自己的 UI ,就可以实现对应位置的嵌入效果。

1
2
3
4
5
6
7
8
9
10
11
12
13
from maya import cmds
window = cmds.window()
cmds.setUITemplate("OptionsTemplate", pushTemplate=1)

column_layout = cmds.columnLayout()
parent = cmds.radioButtonGrp('artAttrColorChannelChoices',q=1,parent=1)
for control in cmds.layout(parent,q=1,childArray=1):
cmds.control(control, e=1, p=column_layout)
if control == "artAttrColorChannelChoices":
cmds.button(label="click me")

cmds.setUITemplate(popTemplate=1)
cmds.showWindow(window)

image

  比如上面的效果,如此就可以在相应的位置嵌入任意的 UI

  最后是怎么将 UI 嵌入到原本的位置,关键就是使用 setParent 命令

1
2
3
4
5
6
7
8
9
10
11
12
from maya import cmds
parent = cmds.radioButtonGrp('artAttrColorChannelChoices',q=1,parent=1)
cmds.setParent(parent)
cmds.setUITemplate("OptionsTemplate", pushTemplate=1)
column_layout = cmds.columnLayout()

for control in cmds.layout(parent,q=1,childArray=1):
cmds.control(control, e=1, p=column_layout)
if control == "artAttrColorChannelChoices":
cmds.button(label="click me")

cmds.setUITemplate(popTemplate=1)

image

  如此就可以了, setParent 会将当前 UI 创建设置到之前的 Layout 下。

使用 Qt 嵌入 UI

  既然 cmds 可以实现 UI 嵌入,那能否利用 Qt API 来实现这个效果呢?

  我也想过将 Layout 转成 Qt Object 的方式进行调用。
  但这个方式获取到的是 QLayout 无法使用 insertWidget 插入,倒是可以使用 addWidget

1
2
3
4
5
6
7
8
9
10
import pymel.core as pm
from Qt import QtWidgets
widget = pm.uitypes.toQtObject("artAttrColorChannelChoices")
parent = widget.parent()
print(parent.objectName())
# columnLayout1065 objectName 和 mel 的 controlName 是一样的
layout = parent.layout()
print(layout)
# <PySide2.QtWidgets.QLayout object at 0x0000014DB0917648>
layout.addWidget(QtWidgets.QPushButton("asd"))

image

  利用上面的方式就可以在 Layout 的最末端添加一个按钮。
  上面使用了 toQtObject 的 pymel API
  背后调用 OpenMayaUI 库通过 objectName 查找到对应的 Qt 组件,然后 wrapInstance 将组件转换为 QObject 类型。
  用 pymel 的方式比较方便。

  要实现 insert 的效果可以利用 takeAt API 将 widget 提取出来再放回去。

1
2
3
4
5
6
7
8
9
10
import pymel.core as pm
from Qt import QtWidgets
widget = pm.uitypes.toQtObject("artAttrColorChannelChoices")
parent = widget.parent()
layout = parent.layout()
index = layout.indexOf(widget)
widget_list = [layout.takeAt(0).widget() for _ in range(layout.count())]
widget_list.insert(index,QtWidgets.QPushButton("click me"))
for widget in widget_list:
layout.addWidget(widget)

image

  如上所示,也完全实现了 cmds 库一样的效果,使用 Qt API 就比 cmds 要灵活很多。
  可以嵌入 Designer 生成的 Widget 等等。

  这里只是展望了一下,我的实现还是基于 cmds 的方式。

总结

  我的工具已经做成了 Maya 插件,启用按照插件的方式加载即可。