简介:MXSPyCOM源代码打包面向3ds Max用户、MaxScript与Python脚本开发者及工具链搭建者,聚焦内置脚本编辑器效率不足、外部编辑器难以联动执行的问题,提供可自行编译和扩展的通信组件与配套示例。压缩包共35个文件、约396KB,以21张各编辑器配置界面截图为主,辅以JSON工作区配置、MaxScript与Python示例、C#项目源码及说明文档,覆盖从环境配置到脚本执行的完整素材。已有402人学习下载,适合需要理解3ds Max与外部编辑器通信机制、复现多编辑器配置或进行二次开发的中级用户。包内还包含COM服务端脚本、任务配置和最小示例,可帮助读者梳理DLL与插件加载、编辑器命令注册及脚本执行流程;截图对Visual Studio、VS Code、Sublime Text、PyCharm等环境的菜单与快捷键设置也有对照价值,便于快速验证配置并复用其调试思路。
1. MXSPyCOM 是什么:让外部 Python 把 MaxScript 当成可调用函数
外部的 Python 脚本要指挥 3ds Max 干活——批量导模型、改材质、按表跑渲染——绕不开一个具体问题:两个进程怎么说话。MXSPyCOM 就是那根线。它是一份 MaxScript 加 Python 的源码包,在 3ds Max 进程里把一个 COM 对象注册出去,Python 侧Dispatch拿到代理,把 MaxScript 当字符串发进去执行,结果再以字符串回来。它不要求你把 Python 装进 Max,也不挑你的 CPython 是 conda 还是官方包,适合那种主流程本来就在 Max 外面、需要用 numpy、OpenCV 先算完再回写 3dsmax 场景的批处理活儿。下面按我实际接它进流水线的顺序拆:先看这条链路怎么走,再跑通最小闭环,然后处理字符串协议最容易翻车的地方。
2. 调用链解剖:从 Dispatch 到 MaxScript 主线程,中间隔了几层
2.1 为什么是 COM,而不是自己开个 socket
3ds Max 的脚本层是单线程的:所有 MaxScript 跑在主线程,跟界面刷新、视口重绘共用一个消息泵。这意味着你没法在 MaxScript 里写一个“一直挂着的服务端循环”——真写了,Max 当场卡死。所以外部程序想跟 Max 说话只有两条路:要么让 Max 主动轮询(读文件、读注册表、挂 timer),要么借用操作系统的进程间调用机制,让调用“插”进 Max 的消息泵里执行。
COM 属于后者。Max 进程把一段脚本注册成一个 COM 类,Windows 负责把 Python 侧的调用投递过来,Max 在自己的消息泵空闲时取出这条调用、在主线程执行、再把结果送回去。整个过程是同步的:Python 那边调用的一行会一直阻塞到 Max 执行完为止。
选它的理由很实在:不用在 Max 里写任何网络或线程代码,不用开端口,不用管防火墙规则,进程生命周期由系统管。代价同样实在:Windows 专用,调用阻塞,参数和返回值只能是 COM 能表达的类型——落到实操上基本就是字符串、数字和布尔。理解这一点,后面大部分坑都能提前预判:任何“大”的东西(几万个顶点、二进制块、图片)都不该走返回值,该走文件。
顺手对比另外两条常见路线。较新版本的 3ds Max 内置了 Python 3 解释器,可以直接在 Max 里跑 Python,但那是 Max 自带的一套环境,你要往里装 numpy、torch 这类库,多版本 Max 并存时非常难受,而且你的主流程得整体搬进 Max。还有一种是纯文件轮询:Python 写一个.ms文件,Max 里挂个 timer 定期fileIn——零依赖、简单粗暴,但延迟高、没有返回值、出错只能靠日志猜。MXSPyCOM 站中间:比内置 Python 灵活,比文件轮询干净,代价是要接受同步阻塞这个前提。
2.2 源码包里要认准的四样东西
拿到包先别急着执行。解压,用编辑器把那个.ms打开,从头到尾扫一遍。你要从里面拿到四类信息,它们决定了后面所有代码怎么写。
| 位置 | 作用 | 你要从里面确认什么 |
|---|---|---|
.ms注册脚本 | 在 Max 进程里注册 COM 类 | ProgID 字符串、暴露的方法名 |
| Python 示例脚本 | 客户端最小用例 | Dispatch的写法、参数格式、返回值样例 |
| 说明文档 | 用法与限制 | 支持的 Max 版本、已知问题 |
| 依赖说明 | 第三方库 | pywin32 的安装方式与位数要求 |
关键在第一行。ProgID 是 Python 侧Dispatch("……")括号里那串名字,不同分支、不同人改过的版本可能改过名字,所以别照抄网上任何一段代码,以你手里这份源码注册的那一行为准。方法名同理,注册脚本里定义了哪些函数、函数签名是几个参数、返回什么类型,源码里写得清清楚楚。这一步花五分钟,能省掉后面半小时对着Invalid class string发呆。
2.3 一次调用的完整时间线
把一次调用拆成六步,每一步都有对应的失败模式:
- Python 侧
Dispatch(progid),向 COM 查询这个 ProgID 对应的类。查不到 →-2147221005 Invalid class string,说明注册没生效、位数不匹配或权限隔离。 - COM 定位到宿主进程(3ds Max),建立跨进程连接。宿主没启动 → 报“远程过程调用失败”,或者直接抛一个很含糊的 OLE 错误。
- 调用被投递到 Max 的消息队列,等主线程空闲。主线程正在跑渲染、正在弹模态对话框 → 这一行永远不返回,也就是俗称的“卡死”。
- MaxScript 在主线程执行你传来的那段代码。语法错、路径不存在、变量未定义 → 报错发生在 Max 侧。
- 执行结果被转换成 COM 能传的类型。数组、结构体这类复杂返回值会被压成字符串或直接丢失。
- 结果回到 Python,
Dispatch那一行解除阻塞。
这六步里,第 3 步和第 5 步是绝大多数人翻车的地方,而且表现形式都是“看起来成功了,其实没有”。第 3 步的问题在于没有超时机制——COM 调用不像 HTTP 有timeout参数,它就是一阻塞到底。第 5 步的问题在于类型窄化——你在 MaxScript 里返回一个含 5000 个元素的数组,回来可能是一串用逗号隔开的文本,也可能是个空值,取决于方法怎么写的。所以后面所有关于“数据怎么传”的讨论,本质都是在绕开这两步。
3. 跑通最小闭环:注册、验证、拿到第一行返回值
3.1 注册前的检查清单
这一步很多人跳过,然后在Dispatch那一行浪费一晚上。四项必须对齐:
- 操作系统:Windows。COM 是 Windows 的东西,这条没有变通。
- 位数:3ds Max 是 64 位,你的 Python 也必须是 64 位。32 位 Python 连不上 64 位 Max 注册的进程外对象,报错不会告诉你原因。
- pywin32:装在你要跑脚本的那个解释器里,
pip install pywin32。conda 环境和系统 Python 混用是重灾区——你在 A 环境装完,在 B 环境里跑脚本,报No module named win32com。 - 权限:这一条最隐蔽。普通用户注册的 COM 对象只对当前登录会话可见;如果 3ds Max 是“以管理员身份运行”启动的,而你从普通命令行跑 Python,两边处在不同的安全上下文里,互相看不见对方注册的东西,报错还是那个
Invalid class string。
提示:Max 和 Python 要么都以普通权限跑,要么都以管理员权限跑,不要一边高一边低。
3.2 在 Max 里加载注册脚本
打开 3ds Max,按 F11 打开 MAXScript 监听器,执行:
-- 路径换成你解压后的实际位置,注意用 @"..." 避免反斜杠被当转义 fileIn @"D:\tools\mxspycom\MXSPyCOM.ms"执行完看监听器有没有报错。没有报错不代表注册成功,还要确认那个类真的进了 COM 表。两种验证方式:一是回到源码里搜注册那一段,确认 ProgID 字符串长什么样;二是直接在 Max 里再执行一次注册脚本,看它是不是提示“已存在”——如果第二次说重复,说明第一次成功了。
调试阶段手动fileIn就行,但正式用的时候要把它挂进启动脚本目录。3ds Max 每次启动会执行scripts\startup下的所有.ms,把注册脚本丢进去,Max 一开就自动注册,省得每次记得手动跑一遍。这个细节看着小,实际是“昨天还能跑今天不行”的头号原因——重启了 Max,注册没了。
3.3 Python 端三条命令验证连通
import win32com.client # 1) 拿到代理,ProgID 以你源码里注册的那串为准 mx = win32com.client.Dispatch("MXSPyCOM.COMObject") # 2) 先列出对象暴露的方法名,确认方法名对得上 for name in dir(mx): if not name.startswith("_"): print(name) # 3) 发一条最短的 MaxScript,验证执行链路 print(mx.EvalMaxScript("maxVersion() as string"))第一行Dispatch是整条链路里信息量最大的一步:成功说明注册、位数、权限三项全对;失败就是前面清单里的某一项没对齐。
第二段遍历方法名,是为了防止你在方法名上想当然。源码里注册了什么方法,这里就能看到什么方法,名字对不上就照着列表改。
第三段是真正的连通性测试。maxVersion()是 MaxScript 里最轻的一个调用,不读文件、不改场景,返回当前版本号。它能在 Python 里打印出版本字符串,说明从 Dispatch 到主线程执行到返回值转换这整条路是通的,后面再出问题就只可能是你的 MaxScript 写错了,排查范围一下缩小到一半。
有个细节值得留意:mx.EvalMaxScript(...)这种动态 Dispatch 写法,在方法名拼错时不一定抛异常,可能返回一个空值,让你误以为执行成功了但没输出。所以第一次接新环境,一定先用dir()对一遍名字。
3.4 返回值先按字符串处理
最小闭环跑通之后,别急着往上堆逻辑。先用几条不同形态的表达式摸清返回值的转换规则,这一步决定了你后面怎么写业务脚本:
# 数字:直接被转成 Python 的 int/float print(mx.EvalMaxScript("1 + 1")) # 字符串:原样回来,但编码问题从这里开始 print(mx.EvalMaxScript("\"hello\"")) # 数组:大概率不是 Python list,先看它到底变成了什么 r = mx.EvalMaxScript("#(1,2,3)") print(type(r), repr(r)) # 场景查询:加上 as string 强制转换,行为最可预测 print(mx.EvalMaxScript("(objects.count) as string"))我一般会强制所有返回值都走as string,然后在 Python 侧自己解析。原因很简单:类型自动转换的规则依赖 COM 类型库和 pywin32 版本,换个机器可能就变了;而字符串是这条链路里唯一保证能原样传回来的东西。多写几个as string、多用几次int()、float(),换来的是行为可预测。
4. 数据往返的边界:转义、中文编码与大返回值的三条路
4.1 反斜杠和引号:字符串套字符串的转义
这条链路里所有代码都是“字符串里包着代码”,于是转义变成了日常。Windows 路径全是反斜杠,MaxScript 里反斜杠是转义符,Python 字符串里反斜杠也是转义符,三层叠在一起,写错一个字符就是路径解析失败。
MaxScript 支持@"..."形式的逐字字符串,里面的反斜杠不做转义;Python 侧用r"..."原始字符串。两边都用逐字写法,能消掉 90% 的转义事故:
path = r"D:\assets\chair_01.max" # 反例:普通字符串拼接,路径里的 \a、\c 会被当转义序列吃掉 # bad = 'loadMaxFile "%s"' % path # 正例:MaxScript 侧用 @"..." 包路径,Python 侧用 r"" 保证原样 cmd = 'loadMaxFile @"{}"'.format(path) mx.EvalMaxScript(cmd)loadMaxFile这类会改场景的命令,执行前建议在 MaxScript 里加一层存在性判断,别指望 Python 侧os.path.exists就够了——Max 所在机器和你 Python 所在机器不一定是同一台(渲染农场场景很常见),路径要在 Max 侧再验一遍:
cmd = ''' ( local p = @"{}" if doesFileExist p then ( loadMaxFile p "ok" ) else ( "missing: " + p ) ) '''.format(path) print(mx.EvalMaxScript(cmd))注意这段 MaxScript 用了块语句( ... ),最后一行是返回值。MaxScript 的块会把最后一个表达式的值作为整体返回,这是让 COM 调用带回有意义结果的标准写法。不写块、直接堆几行语句,返回值往往是最后一条命令的执行结果,很容易变成undefined。
4.2 中文路径与编码:血泪经验在这一节
这是我在这个项目上翻得最惨的一次。场景里有一批模型放在含中文的目录下,Python 侧生成命令、发过去、Max 报文件不存在,可路径明明是对的;换个纯英文路径,一切正常。
原因在编码。MaxScript 内部字符串是宽字符,但这串东西穿过 COM 边界回到 Python 时,pywin32 会按当前系统代码页做一次转换。中文 Windows 的默认代码页是 GBK,于是 UTF-8 的字节流被当 GBK 解,出来就是乱码或者直接抛解码异常。反方向也一样:Python 里以 UTF-8 编码的中文文件名拼进命令字符串,Max 那边按 ANSI 理解,路径就找不到了。
最稳的绕法是:凡是涉及非 ASCII 的内容,不要走返回值,走文件。
-- MaxScript 端:把结果以 UTF-8 写进临时文件 fn writeUtf8 path content = ( (dotNetClass "System.IO.File").WriteAllText path content (dotNetClass "System.Text.Encoding").UTF8 ) writeUtf8 @"C:\temp\mxpy_out.txt" "中文结果"# Python 端:直接按 UTF-8 读回来,绕开 COM 的类型转换 with open(r"C:\temp\mxpy_out.txt", encoding="utf-8") as f: result = f.read()这个写法依赖 .NET 的System.IO.File,MaxScript 通过dotNetClass调用,绝大多数 Max 版本都支持。先在监听器里单行验证一次WriteAllText能不能跑,再往业务里加——不同 Max 版本对dotNetClass的加载时机有差异,稳妥起见在脚本开头显式dotNet.loadAssembly "mscorlib"之类的初始化,跑不通再回头查。
同样地,往 Max 里传中文路径时,我一般让 Python 先把命令写成一个.ms临时文件(UTF-8 带 BOM 或无 BOM 都试一次),然后只发一条极短的命令让 MaxfileIn它。这样中文只存在于文件里,不经过 COM 的字符串转换。
4.3 大返回值:写文件,别 return
几万个顶点的坐标数组,不要试图从EvalMaxScript返回。前面说过,返回值会被压成字符串,几万个浮点数拼成文本再解析,又慢又容易溢出。
标准做法是三步走:MaxScript 端把数据写成文件,Python 端读文件,两边约定好格式。中间格式选 JSON 最省事,MaxScript 没有内置 JSON 序列化,但可以通过dotNet调 .NET 的序列化器,或者干脆手写——顶点数据这种规整结构,手写x,y,z一行一个点比调库更快:
-- 导出一个物体的顶点坐标到 CSV,注意用 StreamWriter 保证编码 fn exportVerts obj path = ( local sw = dotNetObject "System.IO.StreamWriter" path for v in obj.vertices do ( sw.WriteLine ((v.x as string) + "," + (v.y as string) + "," + (v.z as string)) ) sw.Close() "done" ) exportVerts $ (@"C:\temp\verts.csv")import numpy as np # 读回来直接是 ndarray,后面接 OpenCV、接模型推理都顺 verts = np.loadtxt(r"C:\temp\verts.csv", delimiter=",", dtype=np.float32) print(verts.shape)反方向同理:Python 算完一堆坐标要回写给 Max,别拼成巨大的字符串发过去,写到文件,让 Max 读。这条链路上,文件是唯一不受类型和编码限制的通道。
4.4 参数怎么传进去:数组要拼字面量
MaxScript 没有“接受一个 Python list”这种接口,参数只能拼成 MaxScript 源码文本。传数组就拼#(1.0,2.0,3.0),传字典就先序列化成 JSON 字符串再在 Max 侧解析。
def ms_array(values): """把 Python 的数值序列拼成 MaxScript 数组字面量""" # 全部转 float,避免 int/float 混排时 MaxScript 报类型不一致 return "#(" + ",".join("{:.6f}".format(float(v)) for v in values) + ")" cmd = "for v in {} do print v".format(ms_array([1, 2.5, 3])) mx.EvalMaxScript(cmd)两个注意点。一是格式化时别用repr(),某些环境下它会对浮点做科学计数法缩写,MaxScript 那边解析不了;固定小数位最保险。二是数组元素多了以后(比如上千个),这一行命令字符串会非常长,Max 接收和解析都变慢,这时候就该回到 4.3 的做法——写文件。
5. 避坑与排查:五条翻车记录和一个心跳自检脚本
5.1 五条“现象 → 原因 → 解决”
其一,Invalid class string,怎么调都调不通。现象:Python 侧Dispatch直接抛异常,换 ProgID 也不管用。原因通常是三选一:注册脚本压根没在当前这个 Max 会话里跑过;Max 和 Python 位数不一致;两边权限上下文不同(一边管理员一边普通用户)。解决:先在 Max 监听器里手动fileIn一次注册脚本,用dir(mx)确认对象能拿到;再核对位数;最后确认两边权限一致,最简单的方式是两个都从同一个普通命令行/桌面快捷方式启动。
其二,昨天跑得好好的,今天开机就不行了。现象:脚本没改,报错和第一天一样。原因:COM 注册是会话级的,Max 一关,注册就没了,只有启动脚本目录里的.ms才会被自动执行。解决:把注册脚本放进scripts\startup,并且在 Python 侧加一个“连不上就提示用户先启动 Max 并确认注册”的显式报错,别让它变成一个含糊的 OLE 异常。另外,Python 侧如果用的是Dispatch,宿主没启动时它会尝试启动一个——而“启动 3ds Max”这个行为在批处理里通常是你最不想要的,用GetActiveObject更安全:
import win32com.client try: # 只连已经在跑的实例,不会偷偷拉起一个新 Max mx = win32com.client.GetActiveObject("MXSPyCOM.COMObject") except Exception as e: raise RuntimeError("Max 没启动,或注册脚本没加载:%s" % e)其三,调用卡住,十分钟不返回,CPU 占用也不高。现象:Python 那一行挂死,Max 界面看起来正常但没反应。原因:Max 主线程被占了——最常见的是场景里弹了个模态对话框(queryBox、messageBox、缺贴图时的查找对话框),COM 调用排不进消息泵。其次是渲染正在进行,渲染期间脚本执行是被挂起的。解决:先看 Max 屏幕有没有被忽略的对话框;渲染类任务改成提交到渲染队列后立即返回,Python 侧轮询状态;业务脚本里禁止用模态 UI,需要交互就用print打日志。
其四,MaxScript 报错,Python 端却显示成功。现象:Python 拿到了一个空字符串或None,以为跑完了,结果场景没变。原因:MaxScript 的运行时错误默认不会穿透到 COM 层变成 Python 异常,传输层只管送和收,不判断业务成败。解决:所有业务命令都用try/catch包住,把错误信息作为返回值带回来,Python 侧判断返回值是不是以ERR:开头:
( try ( -- 业务代码放这里 "OK" ) catch ( "ERR: " + (getCurrentException()) ) )其五,路径里的中文让一切失灵。现象:英文路径正常,中文路径报文件不存在或返回乱码。原因:4.2 节说的编码转换。解决:命令和结果都走 UTF-8 文件,COM 通道只传 ASCII 的短命令。这条我吃过两次亏,第二次是因为换了一台机器,系统区域设置不同,之前“能跑”的写法又坏了——所以别指望环境一致,直接把非 ASCII 内容赶出 COM 通道。
5.2 心跳与进度轮询:把长任务拆开
既然 COM 调用没有超时,那就不该让任何一次调用超过几秒钟。做法是把长任务切成“启动 + 轮询”两段:MaxScript 侧起一个任务,把进度写进全局变量;Python 侧高频调用一个极短的查询命令读进度。
-- MaxScript 端:全局进度变量 + 任务函数 global mxpy_progress = 0 global mxpy_state = "idle" fn runBatch = ( mxpy_state = "running" mxpy_progress = 0 for i = 1 to 100 do ( -- 实际业务放这里 mxpy_progress = i ) mxpy_state = "done" "finished" )import time mx.EvalMaxScript("runBatch()") # 这一行会阻塞到任务结束 # 更好的做法是把 runBatch 拆成按批次调用,每次只处理一小块, # 然后在两次调用之间用下面这个查询做心跳 def heartbeat(mx): return mx.EvalMaxScript('(mxpy_state + "/" + (mxpy_progress as string))')真实项目里我一般把循环拆成按对象分批,每批 50 个,Python 侧每批调一次,中间顺便打日志、写断点。这样即使 Max 崩了,也知道停在第几批,不用从头再来。这套“分段 + 全局变量 + 轮询”的土办法,比任何花哨的超时机制都管用,因为问题根源在主线程阻塞,而分段是唯一能保证主线程定期回到消息泵的办法。
6. 收尾工程化:包一个可重试的会话对象,再用三个用例验证
散落的Dispatch调用写到最后一定是一团乱。我一般第一步就把它包起来,把连接、重试、错误前缀判断、路径转义全部收进去:
import time import win32com.client class MaxSession: def __init__(self, progid="MXSPyCOM.COMObject", retries=3): self.progid = progid self.mx = None for i in range(retries): try: self.mx = win32com.client.GetActiveObject(progid) break except Exception: # 常见原因:Max 刚启动,注册脚本还在跑 time.sleep(2) if self.mx is None: raise RuntimeError("连不上 Max,检查注册脚本和权限") def eval(self, code): """执行一段 MaxScript,带错误前缀判断""" result = self.mx.EvalMaxScript(code) text = "" if result is None else str(result) if text.startswith("ERR:"): raise RuntimeError(text) return text def ping(self): """最轻的连通性检查,业务开始前跑一次""" return self.eval("maxVersion() as string")包好之后,验证分三层,别一上来就跑全量任务。第一层是ping(),确认链路通;第二层是计数一致性——让 Max 报一次objects.count,Python 侧用预期值比对,确认场景是你要的那一个(同一台机器上开两个 Max 是常态,GetActiveObject连到哪个不一定);第三层是产物校验——导出一个固定物体到固定路径,比对文件大小或哈希,确认写盘这件事在 Max 侧真的发生了,而不是 Python 以为发生了。
第三层最容易被省略,也最容易救你。我曾经因为“返回值是 OK”就放心跑了一整夜的批处理,第二天发现输出目录是空的——MaxScript 里的路径用了相对路径,工作目录是 Max 的安装目录而不是我以为的项目目录。从那以后我每次接新机器、新版本 Max,都强制走一遍这三层验证,尤其是把工作目录打印出来看一眼。
希望帮到你。
本文还有配套的精品资源,点击获取