3.5 小时 · 734 个文件 · 1.5 GB 构建环境 · 262 KB 的
.moc3
中途拆过尾巴、删过两张嘴、被”别乱摸啦”误触了十几次。
起点:一张立绘,和一个拆图的模型
故事不是从两个文件夹开始的,是从一张画开始的。
一张完整的猫耳女仆立绘,先丢进 See-through —— 一个专门做「插画图层分解」的模型:
立绘 PNG ↓ See-through 推理(space-seethrough 权重,约 12.5 GB)32 个图层 后发 / 前发 / 呆毛 / 猫耳 / 眼白 / 瞳孔 / 睫毛 / 眉毛 / 嘴 / 脸 上衣 / 下装 / 腿饰 / 手饰 / 头饰 / 猫 / 尾巴 / 各种小物件 … ↓分层 PSD / PNG这台机器是 RTX 3050 Ti Laptop,4 GB 显存。1024 分辨率的推理峰值要 9 GB 左右,跑不动,所以走的是更小的档位;12.5 GB 权重也不是一次全塞进显存。
(所以我干脆抱着素材去了网吧拆图 —— 网吧那台 5070,是真的好用啊。)
拆出来的这些图层,才被装进一个桌宠项目里 —— 也就是后面反复出现的 catmaid。
两个一模一样的文件夹
手上是一个猫耳女仆的桌宠项目,两个目录:
catmaid-素材源码/ 491 文件 38.3 MBcatmaid-桌宠可运行版/ 579 文件 320.4 MB(多出来的是 node_modules)第一件事是比对。734 个文件,除了 node_modules,逐字节相同。 也就是说”源码版”和”可运行版”其实是一份东西的两份拷贝,改一边必须同步另一边 —— 这个约束贯穿了整个晚上。
这个桌宠的本质是个伪 Live2D:Electron 33 套一个自写的 WebGL2 渲染器,用”权重场变形器”近似 Cubism 的变形,参数名照抄 ParamAngleX、ParamEyeLOpen。32 层 PNG 全部 base64 塞进一个 1 MB 的 model_v2.js 里。
能跑,但不好改。
第一幕:先把它变得可维护
三个问题:
1. 19 个脚本里散落着 22 处硬编码路径
# 改之前ROOT = r"C:\Users\Administrator\Desktop\catmaid"# 改之后ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))bat 里用 for %%I in ("%~dp0..") do set "B=%%~fI",ps1 里用 Resolve-Path。改完在任何机器、任何目录都能跑。
2. 21.6 MB 冗余
203 个 space-seethrough 临时文件、一堆调试 PNG、重复的 model_v2.js。清完 55.7 MB → 38.3 MB,文件 823 → 734。
3. 一个让人抓狂的启动 bug
双击 启动桌宠.bat 报 Cannot find module '...\catmaid" --no-sandbox'。原因是:
start "" "...\electron.exe" "%~dp0." --no-sandbox%~dp0 展开后结尾带反斜杠,"...\catmaid\" 里的 \" 被当成转义引号,引号提前闭合。改成 "%~dp0." 就没事了。
还有一个环境变量坑:如果外部导出了 ELECTRON_RUN_AS_NODE=1,Electron 会以 Node 模式启动,窗口永远出不来,退出码 0x80000003。启动脚本里得先 Remove-Item Env:\ELECTRON_RUN_AS_NODE。
顺手还修了两处视觉问题:右下角那块 V2 · PSD 32 层 标签会穿透显示在角色脸上;body.clean 类(干净模式)忘了隐藏它。
第二幕:给图层做手术
修图流程是这样跑通的:Photoshop 里改好单层 → 导出 PNG → repack.bat 一键回填到 model_v2.js → 两台机器各一份。
这一套不依赖 GPU、不依赖模型推理,改一层两分钟。
然后开始动刀:
- 眼白:原图眼白和皮肤交界处有半透明残边,重画了一版
- 刘海:v2 的前发和 v1 不一致,按 v2 重描
- 右耳:从头部整体里切出来,独立成层(
00_right_ear),放到最底层 - 呆毛:同样切出来,插到发箍后面
这里踩了个经典的乌龙:调了半天”呆毛还是在发箍前面”,最后发现我看的是 v1 那一栏的截图,改的是 v2 —— 两栏并排放在一张对比图里,看串了。后来把对比图重做成”左 v1 / 右 v2”并加了标注才没再犯。
第三幕:真 Live2D —— 死路与岔路
伪 Live2D 玩到最后一定会撞墙:没有真正的网格变形、没有物理、没有 moc3。想要”真”的,只有两条路。
第一条:Cubism Editor。 结论是死路 —— 它有完整的图形界面,但没有任何 CLI / API,AI 无法驱动。人可以用,AI 用不了。
第二条:找开源替代。 翻 GitHub 找到 tsunehimatoi/psd2live:Kotlin + Compose 写的,不需要装 Cubism 就能从 PSD/PNG 自动完成网格、骨骼、物理、参数、动作,直接导出 .moc3 / .cmo3。还自带一个 MCP server(180+ 操作),可以被 AI 直接调用。
就它了。
装环境,三个坑
机器上没有 git、没有 gradle、没有 JDK 21(只有 17 和 25)。手工下了 JDK 21 和 Gradle 9.6.1 的 zip,然后:
坑一:Gradle wrapper 下载超时。 networkTimeout=10000,国内下 services.gradle.org 直接 SocketTimeoutException。解法是手动下发行包,再把 distributionUrl 指向本地文件。
坑二:-replace 把配置改坏了。 distributionUrl=.* 这条正则把 validateDistributionUrl 也吃了,得整份重写。
坑三:PowerShell 吞引号。 想把 JSON 当参数传给 MCP 客户端,无论怎么写引号都被剥掉,报 JSONDecodeError。最后改成把 JSON 写到文件,再传 @file。
编译 11 分钟(Gradle 拉依赖 + Kotlin 编译),MCP 服务起来,端口 23871。
用 MCP 建模
整个过程其实就是四条命令:
project_create_artwork # 30 张 PNG + 每张的 x/y → 建工程、自动分类rig_generate # 自动绑定:网格、骨骼、变形器、物理、动作、表情view_render_poses # 渲染指定参数姿态,用来验证project_export_model # 导出 moc3 + cmo3 + 物理 + 动作 + 表情project_create_artwork 只吃 1–32 层,所以 32 层的模型得先合并掉几层(两只眼睛的上下眼睑各并成一层、耳环并进头发)。
导出结果:
model.moc3 262,784 B Cubism 5model.cmo3 2,642,635 B 可编辑工程model.physics3.json 11,793 B 4 组物理texture_00.png 1,118,649 B+ 4 段动作(idle / blink / nod / shake)+ 4 个表情(笑 / 悲 / 怒 / 惊)它能自己识别图层语义:back hair、front hair、eyewhite、irides、tail、ears-r…… 靠名字分类,然后按部位套不同的绑定策略。这一点比我预想的聪明。
第四幕:让尾巴摇起来
用户问”能不能让猫猫的尾巴上下摆”,于是:
- 拆分:从猫身图层里用多边形把尾巴切出来(1888 px,回拼误差 0 px),尾巴独立成层
- 重建工程:29 → 30 层,尾巴排在猫身之前,切面被猫挡住
- 加摇摆:
swing_put生成Warp_Swing_tail+ 两段参数 + 摆锤物理
{ "targets": ["ArtMeshTail"], "kind": "vertical", "segments": 2, "magnitude": 0.30, "softness": 0.75, "physics_enabled": true}导出的 physics3.json 里多了第四组:
PhysicsSwing_tail 输入: ParamAngleY + ParamBodyAngleY + ParamAngleZ 输出: ParamSwingtail_1, ParamSwingtail_2意思是头一低一抬,尾巴滞后地跟着晃,停下来还会回摆 —— 不是死板的周期动画。
一个反直觉的坑
swing_put 的 targets 一开始传 mesh:ArtMeshTail,报 Swing target not found。试了三种写法才发现要传裸 ID:ArtMeshTail。而 object_find 返回的偏偏是带前缀的 mesh:ArtMeshTail。
第五幕:两张嘴
模型丢进 Cubism Viewer,第一眼就看到两张嘴。
原因很朴素:mouth(一条闭嘴线)和 mouth open(张嘴贴图)都是 visible: true,都没绑差分,一直叠着画。
试了三种修法:
| 方案 | 结果 |
|---|---|
绑成 ParamMouthOpenY 的互斥差分(switch 0 / 1) | 编辑器里生效,导出 moc3 时丢了 |
| 删掉张嘴图,只留闭嘴线 | 参数拉到 1 时,闭嘴线被拉成一块白斑,很难看 |
| 只留张嘴图,当唯一的嘴 | ✅ 闭嘴状态靠网格压缩实现,A(0)/B(0.5)/C(1) 三态都自然 |
第三种是 Live2D 的标准做法:嘴部素材用最大张口,闭口靠压缩。重建工程后 moc3 从 296 KB 降到 262 KB(少了一个嘴层和它的唇形子网格)。
顺带发现 Cubism 的”差分”在运行时是靠部件透明度关键帧实现的,PSD2Live 这一版没把它转过去 —— 记下来,以后要留意。
第六幕:上线
模型部署到博客(star-fall.159911.xyz),替换掉旧的 NOIR 看板娘。
有个细节挺有用:我写了个探针文件到部署目录,然后远程 fetch 它 —— 200 且内容一致,就证明”线上服务的就是这个目录”。同样的手法验证删掉的 NOIR:线上返回 404,说明删除真的生效了。
# 探针→ /pio/_deploy_probe.txt 200 "probe 2026-10-26 00:30:03"# 删掉旧模型后→ /pio/models/NOIR/noir.model3.json 404旧模型还有个隐藏问题:文件被打包了两遍(根目录一份、noir/ 子目录又一份),5.78 MB 里实际只有 2.89 MB 有效内容。
第七幕:全网页鼠标跟随(三次迭代)
需求:鼠标在页面任何位置移动,模型都看向那个方向。而模型跑在一个 280×600 的 iframe 里,只能收到自己框内的鼠标事件。
第一版:合成 mousemove。
iframe 和页面同源,直接监听父文档,把页面坐标按比例映射进画布,再 dispatchEvent:
var nx = e.clientX / parentInnerWidth;emit(rect.left + nx * rect.width, rect.top + ny * rect.height);结果:鼠标滑过页面,模型就说”别乱摸啦”。
原因是 widget 里这两件事吃同一套事件:
onMouseMove(e) → live2DManager.onDrag(x, y) // 视线onTap(e) → hitTest('Head') / hitTest('Body') // 触摸判定我把页面坐标映射到画布中心时,等于告诉它”鼠标永远在模型身上”,命中判定永远成立。
第二版:只调 onDrag,不合成事件。
从 widgetInstance 里 BFS 找对象(深度 12、上限 2 万、getOwnPropertyNames 拿非枚举属性),找到了:
wi.l2d._state.l2d6Model._subdelegates.0._live2DManagermanager.onDrag(nx, ny); // -1~1,内部等价于 setDragging()onDrag 只写 dragX/dragY,模型下一帧读它算头部角度:
addToParamFloat('PARAM_ANGLE_X', dragX * 30, 1)addToParamFloat('PARAM_BODY_ANGLE_X', dragX * 10, 1)addToParamFloat('PARAM_EYE_BALL_X', dragX, 1)完全不经过命中判定,所以不会再误触。成了。
第八幕:气泡
气泡的问题是最后一块硬骨头,也是这次唯一没有 100% 收尾的部分。
现象一:每 5 秒自己弹一句,永远弹。
翻 minified 源码,找到了:
C(text) { bubble.show(text); y = setTimeout(hide, duration) }T() { w(); setInterval(() => C(messages[x++ % len]), interval) } // interval 默认 5000setTimeout(() => { C(欢迎语); T() }, 1500)现象二:气泡卡住不消失。
同一个 bug 的另一面:C(欢迎语) 刚设好隐藏定时器 y,紧接着 T() 里的 w() 就 clearTimeout(y) 把它取消了。以前靠每 5 秒轮播重新 C() 来掩盖,一旦关掉轮播,欢迎语就永远挂在屏幕上。
绕了两圈(改 interval、DOM 里找元素手动隐藏)都没能稳妥解决,最后釜底抽薪:
model.tips = false; // 创建 widget 之前widget 内部 S = null,整个气泡系统不再存在。然后自己写一个:
function showBubble(text, ms) { … opacity / setTimeout(hide, ms) … }台词还是从原配置读(dialog.welcome / dialog.touch),样式照原版复刻,位置按 iframe.getBoundingClientRect() 算,画到父页面上 —— 这样不受 280×600 裁剪,也不会挡住角色。
复盘:AI 能做和不能做
能做的:
- 大规模、重复、需要精确到像素的机械劳动(734 文件比对、1888 px 切割、20 次改配置)
- 反向工程压缩过的代码(从 659 KB minified 里挖出
onDrag和气泡 bug) - 通过 MCP 驱动一个只有 GUI 的桌面程序(PSD2Live)
- 远程验证部署(探针文件 + fetch)
不能做的:
- 打开 Cubism Editor 手工绑一条物理 —— 它没有 API,这条路是封死的
- 看见渲染结果 —— 这台机器没有 GPU,headless 浏览器出不了 WebGL 画面,最后一步永远得人来点
- 猜准产品的意图 —— “气泡显示页面内容”这句,我理解错了两次,第三次才问到点子上
数字
| 项目 | 数值 |
|---|---|
| 时长 | 3.5 小时 |
| 处理文件 | 734 → 491(去重后) |
| 清理冗余 | 21.6 MB(桌面宠) + 9.9 MB(模型目录) |
| 清理构建环境 | 1.5 GB(JDK + Gradle + 源码 + 缓存) |
| 编译耗时 | 11 分钟 |
| 模型 | 30 层 → 262,784 B moc3 |
| 物理 | 4 组(后发 / 前发 / 果冻眼 / 尾巴) |
| 动作/表情 | 4 + 4 |
| 鼠标跟随迭代 | 3 版 |
遗留
- 气泡在鼠标指向页面内容时的行为,还需要现场截图确认(见备忘)
- 上下跟随幅度可以再调(
onDrag的 Y 建议乘 1.5~2) - 点画布任意位置都会说话,还没精确到”只有点到模型”
全程 0 行手写建模代码,0 次打开 Cubism Editor。
如果这篇文章对你有帮助,欢迎分享给更多人!
部分信息可能已经过时