前言

  因为有 Maya 技术积累的自信,对于做 3dsMax 的二次开发其实我是充满了自信的。
  虽然我之前不怎么用 3dsMax 但是,我知道 3dsMax 按照自动桌的尿性,帮助文档可以解君愁。

  因此之前我被介绍的时候,大家以为我是搞 3dsMax 我也没有去否认了,反正大不了就看看文档写就好了。

  果不其然昨晚就收到了相关的开发需求,而且需求还是相对简单的。
  因此我就看了半套教程配合自动桌的文档解决了问题。

需求说明

  首先明确一下这次开发的需求。
  3dsMax 导出 FBX 的时候每次都会弹出一个导出选项的窗口。
  而且每一次都是默认的设置,尽管这里的设置可以保存预设切换读取,但是对于制作人员来说还是非常不友好。
  因此需求很简单,就是导出的时候可以使用上次的设置,简单清晰就给你导出了。

前期准备

  毕竟自己还是没有接触过 maxscript ,相应的教程准备还是要看的。
  在B站搜罗了一轮之后,发现 DigitalTutors 有教程,非常棒。

  另外看到教程的 13 集导出的方法,其实就和需求想要的效果差不多
  另外我可以通过 PySide 制作界面,剩下我去搞 maxscript 图形编程的问题。

  剩下的解决方案基本上是通过 Autodesk 官方文档找了,Stack Overflow里面也没有多少有价值的提问。
  网上的教程也不多(:з」∠)

开发填坑

  首先测试的时候,我发现 max 居然还不能通过拖拽执行 python 脚本
  因此只好通过 maxscript 调用 python 间接实现调用。

  毕竟是要搞 FBX 的导出,因此我在 3dsMax 的官方帮助里面搜索 FBX export
  可以找到这个页面

  而且上面教程里也有用到这个命令。
  最重要的是 页面 里面提示了我可以通过 OpenFbxSetting() 打开设定面板。

  测试了之后,这里打开的 设定面板 , 并且这里做的所有操作都是可记录,就没有原本 FBX 导出那么蛋疼了。

  于是我花了一些时间绘制了一个 ui ,然后转换成 py 文件,在 3dsMax 里面测试能否实现 Qt 的编程
  一切都很顺利,并没有太棘手的问题。


  后续就到了关键的输出步骤了 ,我在官方文档上找到了相关的页面

1
2
theClasses = exporterPlugin.classes
exportFile (GetDir #scene + "/exportTest" ) using:theClasses[1]

  我看到输出的类型还需要找到对应的插件去 using 才可以,巨麻烦。
  而且 GetDir 的 #scene 也不知道怎么通过 pymxs 获取 , #noPrompt 标记也不知道如何启用。
  幸好教程也有同样的写法,Using 可以直接写 FBXEXP 插件名称即可
  默认加载插件就可以识别得到这个变量,省去了过滤的步骤。

  不过 GetDir 还是不知道怎么才能正确使用起来。

1
2
from pymxs import runtime as rt
rt.GetDir("#scene")

  原以为是这样子调用,但是这样会报错,而且错得莫名其妙。
  我也参照了 GetDir 的文档,但是对于 #scene 这些类型的变量依然一无所获
  后来一直没有解决,因此我就通过 python 调 maxscript 来解决这个问题
  具体的操作可以参照 文档

  当然这样的解决方案不太让我满意,于是我写文章之际做了进一步的研究。
  最后我按照教程的说法,用 classof 函数测一下带 # 这个符号的变量类型
  结果发现这些变量并不是 string 而是 name,于是又查了一下 name 这种类型的变量。 文档
  现在好了,这是一个独特的类型,那应该是可以通过 Name 来进行类型转换的。
  于是我 dir(rt) 罗列出所有的属性,果然有 name 的函数可以用来进行类型转换。
  因此上面要起作用需要转化成下面的写法。

1
2
from pymxs import runtime as rt
print rt.GetDir(rt.name("scene"))

  解决了这个棘手的类型转换问题之后,也就顺理成章可以解决掉 #noPrompt 标记的问题

1
2
from pymxs import runtime as rt
rt.exportFile(r"C:\Users\timmyliang\Desktop\test\a.fbx",rt.name("NOPROMPT"),using=rt.FBXEXP)

  另外 maxscript 有个神奇的地方就是变量不严格区分大小写。
  因此 noprompt 大小写都无所谓的,但是和 python 相关的部分就要好好区分清楚。

数据记录

  由于美术那边希望可以记录到上次脚本调用的路径,因此还需要做一个存储功能。
  我最初的想法就是通过json文件进行记录,但是这样脚本就会很臃肿。
  于是我打算将路径记录到脚本自身。
  因此我要在 maxscript 里面的开头两行做路径记录的注释,然后通过 python.Execute 来执行 PySide Qt 的界面。
  然后这个 python 调用里面有集成了 maxscript 的调用,真的是够绕的。
  因为 maxscript 是不支持 单引号 的,因此调用的时候还要对 python 的双引号做转义处理。
  又因为这里的python内部调用了 maxscript ,所以这里的双引号无法用单引号避开,尤为需要注意。

  于是继续到存储的操作上。
  由于 python 文件是通过 maxscript 调用的,因此就无法通过 __file__ 内置变量来获取脚本路径

  好在 maxscript 还是集成了获取脚本路径的函数 getSourceFileName() 文档
  获取了脚本路径之后需要获取脚本的目录 虽然可以通过字符串处理来获取,但是希望找到更加简洁的方式。
  于是找了一下发现了 getFilenamePath 函数 文档

  于是后续的操作就是将这个字符串拼接到 python 脚本内部就可以了。

  下面就是代码,获取路径之后 会修改第二行的路径 ,因此不能修改脚本的内容,否则就乱套了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
-- NOTE 下面这一行注释会通过 python 进行读取设置到工具的路径上
-- C:\Users\timmyliang\Desktop\test

-- https://help.autodesk.com/view/3DSMAX/2016/ENU/?guid=__files_GUID_9588A886_A811_4C05_9A07_B6A68C969050_htm
-- NOTE 获取当前脚本的路径
SCRIPT_PATH = getSourceFileName()

-- http://help.autodesk.com/view/3DSMAX/2015/ENU/?guid=__files_GUID_0EE531B2_6FF8_4D0B_ACA1_5400E0B9D604_htm
-- NOTE 获取当前脚本的文件夹
-- script_folder = getFilenamePath(script_path)

code = "__author__ = 'timmyliang'\n__email__ = '820472580@qq.com'\n__date__ = '2019-11-26 14:52:34'\n\nu'''\nMoreFun - FBX导出工具\n简化 3dsMax FBX 导出流程\n'''\n\nimport os\nimport sys\ntry:\n from PySide.QtCore import *\n from PySide.QtGui import *\nexcept:\n from PySide2.QtWidgets import *\n from PySide2.QtCore import *\n from PySide2.QtGui import *\n\nimport MaxPlus\nimport pymxs\nimport json\nfrom pymxs import runtime as rt\n\nSCRIPT_PATH = r'"+SCRIPT_PATH+"'\n\nclass Ui_Form(object):\n def setupUi(self, Form):\n Form.setObjectName('Form')\n Form.resize(349, 184)\n Form.setWhatsThis('')\n self.verticalLayout = QVBoxLayout(Form)\n self.verticalLayout.setObjectName('verticalLayout')\n self.Options_BTN = QPushButton(Form)\n self.Options_BTN.setObjectName('Options_BTN')\n self.verticalLayout.addWidget(self.Options_BTN)\n self.horizontalLayout = QHBoxLayout()\n self.horizontalLayout.setObjectName('horizontalLayout')\n self.label = QLabel(Form)\n self.label.setObjectName('label')\n self.horizontalLayout.addWidget(self.label)\n self.Path_LE = QLineEdit(Form)\n self.Path_LE.setObjectName('Path_LE')\n self.horizontalLayout.addWidget(self.Path_LE)\n self.Path_BTN = QPushButton(Form)\n self.Path_BTN.setObjectName('Path_BTN')\n self.horizontalLayout.addWidget(self.Path_BTN)\n self.verticalLayout.addLayout(self.horizontalLayout)\n self.Export_BTN = QPushButton(Form)\n self.Export_BTN.setObjectName('Export_BTN')\n self.verticalLayout.addWidget(self.Export_BTN)\n spacerItem = QSpacerItem(20, 40, QSizePolicy.Minimum, QSizePolicy.Expanding)\n self.verticalLayout.addItem(spacerItem)\n\n self.retranslateUi(Form)\n QMetaObject.connectSlotsByName(Form)\n\n def retranslateUi(self, Form):\n Form.setWindowTitle(u'MoreFun - FBX导出工具')\n self.Options_BTN.setText(u'FBX导出选项')\n self.label.setText(u'输出路径')\n self.Path_BTN.setText(u'选择路径')\n self.Export_BTN.setText(u'FBX导出')\n\nclass MF_FBXEXP_UI(QWidget,Ui_Form):\n def __init__(self):\n super(MF_FBXEXP_UI,self).__init__(MaxPlus.GetQMaxWindow())\n\n self.setupUi(self)\n self.Options_BTN.clicked.connect(self.openOptions)\n self.Path_BTN.clicked.connect(self.selectPath)\n self.Export_BTN.clicked.connect(self.exportFBX)\n \n # NOTE 防止窗口多开\n for child in MaxPlus.GetQMaxWindow().children():\n if 'MF_FBXEXP_UI' in str(type(child)):\n child.close()\n\n self.show()\n\n # NOTE 读取 maxscript 第一行的注释获取\n self.initPath()\n\n def initPath(self):\n\n if not os.path.exists(SCRIPT_PATH):\n return\n\n with open(SCRIPT_PATH,'r') as f:\n self.script_data = f.readlines()\n first_line = self.script_data[1].strip()\n \n self.Path_LE.setText(first_line[3:])\n\n def openOptions(self):\n rt.OpenFbxSetting()\n\n def selectPath(self):\n\n # NOTE 根据现有的路径打开路径选择\n directory = os.path.dirname(self.Path_LE.text())\n if not os.path.exists(directory):\n directory = ''\n\n output_path = QFileDialog.getExistingDirectory(self,dir=directory,caption=u'导出FBX文件目录') \n \n # NOTE 如果关闭窗口或者取消\n if not output_path :\n return\n \n self.Path_LE.setText(output_path)\n\n if not os.path.exists(SCRIPT_PATH):\n return\n\n self.script_data[1] = '-- %s\\n' % output_path\n with open(SCRIPT_PATH,'w') as f:\n f.writelines(self.script_data)\n\n def exportFBX(self):\n \n output_path = os.path.normpath(self.Path_LE.text()).replace('\\\\','/')\n\n if output_path == '':\n QMessageBox.warning(self,u'警告',u'路径不能为空')\n return\n \n folder = os.path.dirname(output_path)\n if not os.path.exists(folder):\n QMessageBox.warning(self,u'警告',u'当前给定目录不存在,请重新选择路径')\n return\n\n # NOTE 无窗口导出FBX\n MaxPlus.Core.EvalMAXScript('''\n if selection.count != 0 then(\n sel_list = selection as array\n deselect sel_list\n for obj in sel_list do(\n select obj\n path_name = \"{0}/\" + obj.name + \".fbx\"\n exportFile path_name #noPrompt selectedOnly:True using:FBXEXP\n )\n ) else (\n file_name = getFilenameFile maxfilename\n path_name = \"{0}/\" + file_name + \".fbx\"\n exportFile path_name #noPrompt selectedOnly:False using:FBXEXP\n )\n '''.format(output_path))\n\n\nif __name__ == '__main__':\n FBXEXP = MF_FBXEXP_UI()\n \n"

-- NOTE 运行 Python 代码
-- print code
python.Execute code

菜单集成

  其实到这一步就基本已经完成了 美术 想要的需求了。
  只是后面还有一个优化方案,就是希望可以开启 3dsMax 的时候可以自动显示出插件。

  考虑到 Maya 颇为复杂的配置,我还是咨询了我的导师看看 TA 组内是否有现成的配置方案。

  于是 导师 就给我发了以前项目用的脚本。
  用起来也非常方便,只需要将脚本拖拽到 Max 就会生成相应的下拉菜单,而且这个菜单生成是永久记录的。
  一次运行解君愁。


  我模仿导师发给我的脚本也做了一个下拉菜单。
  菜单需要用到 MacroScript 来进行触发调用。
  然后通过 menuMan.getMainMenuBar() 来获取 Max 的主菜单进行添加操作。
  我看了一下文档,也有相关的代码案例可以参考。

  最后顺利将自己的脚本集成到了上面的下拉菜单上。
  然后当我关了 3dsMax 重开之后,我发现我输入的中文字符全部变成了 ? (:з」∠)

  后来又在网上查了不少的文档,这个Character Encoding 文章说得很透彻
  详细阐述了 脚本编辑器 如果不按照正确的方式存储, ascii 以外的字符统统变成问号。
  我突然明白上面的菜单调用肯定也是调用了 用户设置 的文件,然后通过这些文件在开启 Max 的时候生成菜单的。
  于是我又突然想起昨天看到的 B站 上解决Max的方法,操作其实就和删除 Maya 我的文档的文件是一样的。
  想不 Max 也和 Maya 一样有记录用户设置的文件夹,只是和 Maya 放的 位置不一样而已。

  于是翻了视频看 找到了目标路径 C:\Program Files\Autodesk\3ds Max <ReleaseNumber>\
  删除这个文件夹之后, Max 就会恢复到默认的设置上了。

  于是我用 VScode 在这个目录下寻找我生成的 文件。
  果不其然可以找到 \ENU\usermacros 可以找到菜单调用的代码,然后这里的代码就是全部 ? 号来的
  主要原因就是保存的时候没有按照上面 Character Encoding 文章 配置的问题导致的。
  我看到文章中有提到 Preference : Files 里面的设置。
  我试着把 save strings in legacy non-scene files using UTF8 这个选项勾选之后,奇迹就出现了。
  这样保存的 msc 文件就是正常的,而且关了重开也没有任何问题。

  那么下面的问题就是如何通过代码来勾选这个选项。
  最初我想到的就是 Macro Recorder 录制这个操作,类似 Mel 看回显可以发现很多细节。
  然而 Max 到这里并没有这么智能,Maya 的撤销大法也不管用。

  于是我就卡住了,还是得去官网查找文档

  后来偶然搜索到了这个文章
  这里提到可以通过 修改 max.ini 来解决这个问题。
  而且的确 max.ini 里面是有 LegacyFilesCanBeStoredUsingUTF8 这个属性
  当我修改了 Preference 之后这个文件就会随之更新。
  但是我手动修改这个属性,打开 Preference 似乎也并没有读取这里的 设置。

  于是就在僵持的时候,我突然发现还可以通过 C++ SDK 来解决这个问题。
  于是搜了一下 MaxPlus 的相关函数,还真的让我找到了我想要的东西

1
2
3
import MaxPlus as ms
if not ms.PreferencesFileEncoding.LegacyFilesCanBeStoredUsingUTF8():
ms.PreferencesFileEncoding.SetLegacyFilesCanBeStoredUsingUTF8(True)

  执行脚本之前可以先勾选这个设置就万无一失了。


  最后还要处理路径存储的问题,因为现在切换为菜单创建
  因此存储不能通过脚本写入来实现。
  这里我借助 Python tempfile 库来获取系统的临时文件夹
  在临时文件夹里面创建一个存储路径的 txt 文档。
  变相解决了这个存储的问题。

总结

  另外我发现 maxscript 是支持多行字符串的,这点比 Mel 要好很多。
  这样就可以保留 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
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194

-- https://help.autodesk.com/view/3DSMAX/2016/ENU/?guid=__files_GUID_9588A886_A811_4C05_9A07_B6A68C969050_htm
-- NOTE 获取当前脚本的路径
-- SCRIPT_PATH = getSourceFileName()

-- http://help.autodesk.com/view/3DSMAX/2015/ENU/?guid=__files_GUID_0EE531B2_6FF8_4D0B_ACA1_5400E0B9D604_htm
-- NOTE 获取当前脚本的文件夹
-- script_folder = getFilenamePath(script_path)

-- NOTE 设置 utf-8 保存 | 避免中文显示乱码
python.Execute "
import MaxPlus as ms
if not ms.PreferencesFileEncoding.LegacyFilesCanBeStoredUsingUTF8():
ms.PreferencesFileEncoding.SetLegacyFilesCanBeStoredUsingUTF8(True)
"

macroScript OP
category: "OP FBX Export"
tooltip: "批量导出FBX工具"
(
-- NOTE 运行 Python 代码
python.Execute "
__author__ = 'timmyliang'
__email__ = '820472580@qq.com'
__date__ = '2019-11-26 14:52:34'

u'''
MoreFun - FBX导出工具
简化 3dsMax FBX 导出流程
'''

import os
import sys
import tempfile
try:
from PySide.QtCore import *
from PySide.QtGui import *
except:
from PySide2.QtWidgets import *
from PySide2.QtCore import *
from PySide2.QtGui import *

import MaxPlus
import pymxs
import json
from pymxs import runtime as rt

# NOTE 临时文件记录选择的路径
SCRIPT_PATH = os.path.join(tempfile.gettempdir(),\"MF_FBXEXP_PATH.txt\")

class Ui_Form(object):
def setupUi(self, Form):
Form.setObjectName('Form')
Form.resize(349, 184)
Form.setWhatsThis('')
self.verticalLayout = QVBoxLayout(Form)
self.verticalLayout.setObjectName('verticalLayout')
self.Options_BTN = QPushButton(Form)
self.Options_BTN.setObjectName('Options_BTN')
self.verticalLayout.addWidget(self.Options_BTN)
self.horizontalLayout = QHBoxLayout()
self.horizontalLayout.setObjectName('horizontalLayout')
self.label = QLabel(Form)
self.label.setObjectName('label')
self.horizontalLayout.addWidget(self.label)
self.Path_LE = QLineEdit(Form)
self.Path_LE.setObjectName('Path_LE')
self.horizontalLayout.addWidget(self.Path_LE)
self.Path_BTN = QPushButton(Form)
self.Path_BTN.setObjectName('Path_BTN')
self.horizontalLayout.addWidget(self.Path_BTN)
self.verticalLayout.addLayout(self.horizontalLayout)
self.Export_BTN = QPushButton(Form)
self.Export_BTN.setObjectName('Export_BTN')
self.verticalLayout.addWidget(self.Export_BTN)
spacerItem = QSpacerItem(20, 40, QSizePolicy.Minimum, QSizePolicy.Expanding)
self.verticalLayout.addItem(spacerItem)

self.retranslateUi(Form)
QMetaObject.connectSlotsByName(Form)

def retranslateUi(self, Form):
Form.setWindowTitle(u'MoreFun - FBX导出工具')
self.Options_BTN.setText(u'FBX导出选项')
self.label.setText(u'输出路径')
self.Path_BTN.setText(u'选择路径')
self.Export_BTN.setText(u'FBX导出')

class MF_FBXEXP_UI(QWidget,Ui_Form):
def __init__(self):
super(MF_FBXEXP_UI,self).__init__(MaxPlus.GetQMaxWindow())

self.setupUi(self)
self.Options_BTN.clicked.connect(self.openOptions)
self.Path_BTN.clicked.connect(self.selectPath)
self.Export_BTN.clicked.connect(self.exportFBX)

# NOTE 防止窗口多开
for child in MaxPlus.GetQMaxWindow().children():
if 'MF_FBXEXP_UI' in str(type(child)):
child.close()

self.show()

# NOTE 读取路径
self.initPath()

def initPath(self):

if not os.path.exists(SCRIPT_PATH):
return

with open(SCRIPT_PATH,'r') as f:
path = f.read()

self.Path_LE.setText(path)

def openOptions(self):
rt.OpenFbxSetting()

def selectPath(self):

# NOTE 根据现有的路径打开路径选择
directory = os.path.dirname(self.Path_LE.text())
if not os.path.exists(directory):
directory = ''

output_path = QFileDialog.getExistingDirectory(self,dir=directory,caption=u'导出FBX文件目录')

# NOTE 如果关闭窗口或者取消
if not output_path :
return

self.Path_LE.setText(output_path)

with open(SCRIPT_PATH,'w') as f:
f.write(output_path)

def exportFBX(self):

output_path = os.path.normpath(self.Path_LE.text())
output_path = output_path.replace(\"\\\\\",\"/\")

if output_path == '':
QMessageBox.warning(self,u'警告',u'路径不能为空')
return

folder = os.path.dirname(output_path)
if not os.path.exists(folder):
QMessageBox.warning(self,u'警告',u'当前给定目录不存在,请重新选择路径')
return

# NOTE 无窗口批量导出FBX
MaxPlus.Core.EvalMAXScript('''
if selection.count != 0 then(
sel_list = selection as array
deselect sel_list
for obj in sel_list do(
select obj
path_name = \"{0}/\" + obj.name + \".fbx\"
exportFile path_name #noPrompt selectedOnly:True using:FBXEXP
)
) else (
file_name = getFilenameFile maxfilename
path_name = \"{0}/\" + file_name + \".fbx\"
exportFile path_name #noPrompt selectedOnly:False using:FBXEXP
)
'''.format(output_path))


if __name__ == '__main__':
FBXEXP = MF_FBXEXP_UI()

"
)

----------------------------
-- NOTE 创建面板
----------------------------

mainMenuBar = menuMan.getMainMenuBar()
ori_OP_menu = menuMan.findMenu("OP")
if (not ori_OP_menu == undefined) do
(
menuMan.unRegisterMenu ori_OP_menu
)
subMenu = menuMan.createMenu "OP"
FBXExportItem = menuMan.createActionItem "OP" "OP FBX Export"
subMenu.addItem FBXExportItem -1
subMenuItem = menuMan.createSubMenuItem "FBX批量导出工具" subMenu
subMenuIndex = mainMenuBar.numItems() - 1
mainMenuBar.addItem subMenuItem subMenuIndex
menuMan.updateMenuBar()