Files
UmaViewer/docs/pmx-export-standard-and-postmortem.md

272 lines
13 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# PMX 导出规范与修复复盘
最后更新:2026-09-26
本文记录 UmaViewer PMX 导出修复的根因、实现原则、PMX/MMD 兼容边界和回归验证方法。目标是让后续修改继续面向通用 PMX/MMD 工具链,而不是只保证某一个加载器能够读取。
## 1. 结论
这次故障不是单一骨骼角度错误,而是三个问题叠加:
1. 导出参考姿势来自不完整、时机不稳定的运行时快照,特殊预览动作会残留到 PMX 基础姿势。
2. Unity 运行时骨架被直接当作 MMD 控制骨架导出,缺少稳定的 MMD 控制层级、腿部 IK 和严格的索引关系。
3. PMX IK 角度限制错误复用了空间向量的坐标转换函数,导致符号翻转、上下限倒置,并最终使部分求解器在执行角度限制时崩溃。
最终方案遵循以下原则:
- PMX 导出结果必须与当前预览动作无关。
- PMX 与 VMD 必须使用同一套项目参考姿势。
- 导出是一个有完整回滚能力的事务。
- PMX 字段按语义分类读写,位置、方向、角度不能共用同一种转换。
- PMX 文件格式要求与 MMD 生态骨骼约定必须分开描述。
## 2. “PMX 标准”与“MMD 标准骨架”的边界
需要先澄清一个容易造成错误实现的概念:
- PMX 2.x 是二进制模型格式规范,规定数据布局、索引、骨骼标志、IK 数据、权重和材质等字段。
- MMD 标准骨骼是为了让 VMD 动作能够按名称和结构复用而形成的生态约定。
- `全ての親`、`グルーブ`、`腰`、`上半身2`、`足IK親` 等通常被称为准标准骨骼或事实标准扩展,并不是 PMX 文件语法要求。
- PMX 没有规定模型必须是 A-pose 或 T-pose。它要求顶点、骨骼位置、权重和动作参考基准彼此一致。
- 本项目双臂各下压 `38.5°` 是 UmaViewer 的 MMD/VMD 对齐参数,不应写成适用于所有模型的通用 MMD 常量。
因此,本项目所说的“MMD 参考 A-pose”是导出约定:先恢复角色初始化姿势,再对左右上臂施加 `-38.5°`、`+38.5°` 的本地旋转,并让 PMX 与 VMD 共用这一基准。
## 3. 故障现象与根因
### 3.1 IK 限制导致加载器崩溃
日志中的关键错误是:
```text
min > max, or either was NaN. min = -0.0087, max = -3.1415927
```
预期膝盖 X 轴限制为:
```text
lower = -π
upper = -0.0087
```
旧实现使用通用 `WriteVector3` 写入 IK 角度。该函数面向空间坐标,会执行模型倍率和 Unity/PMX 坐标轴转换。角度限制不是位置向量,经过同一转换后端点符号和顺序发生变化,文件中出现 `lower > upper`。
修复后:
- IK 限制使用专用的原始 `float3` 读写函数。
- 写入前拒绝 `NaN` 和 `Infinity`。
- 写入和读取时逐轴规范化上下限。
- 骨架生成结束后再次校验 IK 目标、链接索引和限制区间。
这里的核心经验是:两个字段即使二进制布局都是 `float3`,也不代表它们拥有相同的坐标语义。
### 3.2 预览动作污染基础姿势
旧快照只保存身体主蒙皮引用骨骼的世界位置和世界旋转,而且捕获时机位于模型合并流程中。它没有覆盖:
- Animator 可能驱动的非蒙皮节点;
- 控制节点和辅助层级;
- 局部缩放;
- 父节点变换对后代节点的影响;
- 模型合并、身高处理和动画加载之间的初始化时序。
这解释了为什么某些动作导出正常,而某些动作导出后在 Blender 中像被额外套用动作:残留状态取决于该动作实际驱动了哪些 Transform。
修复后,初始化参考姿势在模型合并和身高设置完成、预览动画加载前捕获,并保存完整模型层级的:
```text
localPosition
localRotation
localScale
```
恢复时同样使用本地变换。这样父子层级按原始局部关系重建,不会因父节点的旋转或缩放产生二次偏移。
### 3.3 导出过程缺少事务边界
PMX 导出会临时改变 Animator、表情、眼动、动态骨、物理和 Transform。旧流程中的恢复逻辑分散,一旦纹理、模型构建或文件写入中途抛出异常,就可能把角色留在导出状态。
现在由 `PMXExportStateSnapshot` 统一执行:
```text
捕获当前运行时状态
-> 冻结 Animator、眼动和物理
-> 恢复初始化姿势
-> 应用项目 MMD 参考 A-pose
-> 构建并写出 PMX
-> finally 清理临时资源
-> 恢复表情、完整 Transform 和运行时组件
```
`Dispose()` 使用嵌套 `finally`,保证某一类状态恢复失败时,后续恢复步骤仍会执行。
### 3.4 Unity 骨架不能直接等同于 MMD 控制骨架
Unity 运行时层级包含蒙皮骨、控制节点、碰撞节点、Handle、Pole、Target 和其他辅助对象。全部导出会造成:
- 非动画骨进入 PMX;
- 父子关系不符合 MMD 动作预期;
- 权重引用不存在或错误的骨骼;
- 缺少 `センター`、`下半身` 和足 IK 等控制层。
新的骨架构建流程从 `SkinnedMeshRenderer.bones` 收集实际蒙皮引用,再补入动作和父链所需骨骼,并过滤明确的运行时辅助节点。被过滤骨骼的顶点权重会转交给最近的有效祖先,而不是无条件落到索引 0。
## 4. 当前导出骨架约定
当前体干控制层级为:
```text
全ての親
└─ センター
└─ グルーブ
└─ 腰
├─ 上半身
│ └─ 上半身2
└─ 下半身
├─ 左足
└─ 右足
```
腿部 IK 控制层级为:
```text
全ての親
├─ 左足IK親
│ └─ 左足IK
│ └─ 左つま先IK
└─ 右足IK親
└─ 右足IK
└─ 右つま先IK
```
足 IK 的目标和链接关系为:
```text
足IK
target: 足首
links : ひざ(有限制) -> 足(无限制)
つま先IK
target: つま先
links : 足首
```
膝盖限制使用 PMX 文件坐标语义下的负 X 区间:
```text
X: [-π, -0.0087]
Y: [0, 0]
Z: [0, 0]
```
此区间用于避免膝盖向错误方向折叠。`lower <= upper` 必须逐轴成立,且所有值必须是有限浮点数。
## 5. PMX 导出必须遵守的规则
### 5.1 索引完整性
- 父骨骼索引只能是 `-1` 或有效骨骼索引。
- 骨骼不能以自身作为父骨骼。
- 使用骨骼索引作为尾端时,尾端索引必须有效。
- IK 目标和每个 IK 链接都必须引用有效骨骼。
- 顶点权重不能引用未导出的 Unity Transform。
- 过滤骨骼后必须重新建立 Transform 到 PMX 索引的映射。
### 5.2 标志与后续数据一致
PMX 骨骼数据是由标志控制的可变结构:
- 尾端为骨骼索引或位置偏移,只能按对应标志写入一种。
- IK 标志开启时,必须写入目标、迭代次数、单次限制角、链接数量和链接数据。
- IK 链接开启角度限制时,必须继续写入下限和上限两个 `float3`。
- 读写器必须保持对称,不能只修改 Writer 而让 Reader 延续旧语义。
### 5.3 数值和单位
- PMX 浮点数据写入前应检查 `NaN` 和 `Infinity`。
- IK 单次限制角和链接角度上下限使用弧度。
- 位置向量可以经过模型尺度与坐标系转换。
- IK 欧拉角限制不能经过位置倍率。
- 通用坐标翻轴函数不能直接用于角度区间;角度应在 PMX 坐标语义下明确构造和写入。
### 5.4 基础姿势一致性
- 导出的顶点位置、骨骼位置和权重必须来自同一参考姿势。
- 导出结果不能依赖按钮按下时 Animator 恰好停在哪一帧。
- 禁止用 `Animator.Rebind()` 作为不透明的“复位”手段,除非同时证明它不会改变参考姿势、Animator 层状态和用户预览状态。
- 若模型初始化后会新增可动画 Transform,应重新评估参考姿势快照的覆盖时机。
- PMX 和 VMD 使用不同参考姿势会造成动作应用后的固定肩臂偏差。
### 5.5 MMD 名称兼容
VMD 主要通过骨骼名匹配动作,因此日文主名称必须稳定且使用正确字符。尤其要注意:
- `左足IK`、`右足IK`、`左つま先IK`、`右つま先IK` 中的 `IK` 是全角字符。
- `左ひじ`、`右ひじ` 等名称应保持 MMD 生态常用写法。
- 英文名用于辅助识别,不能替代日文主名称的动作兼容作用。
- 准标准骨骼不是 PMX 语法强制项,应按目标动作和控制需求加入,不能把所有扩展骨都称为“PMX 必需骨”。
## 6. 代码职责
| 文件 | 职责 |
| --- | --- |
| `Assets/Scripts/Exporters/ModelExporter.cs` | PMX 导出事务入口、网格/材质/权重构建和文件写出 |
| `Assets/Scripts/Exporters/PMXExportStateSnapshot.cs` | 捕获、冻结、应用参考姿势和完整恢复运行时状态 |
| `Assets/Scripts/Exporters/PMXBoneExporter.cs` | 收集有效骨骼、建立 MMD 控制层级、生成足 IK 和校验索引 |
| `Assets/Scripts/UmaContainerCharacter.cs` | 在稳定初始化时机捕获完整本地参考姿势 |
| `Assets/Scripts/Exporters/VMDRecorder/UnityHumanoidVMDRecorder.cs` | 与 PMX 共用项目 MMD 参考 A-pose |
| `Assets/Scripts/Exporters/UnityPMXRuntimeLoader/LibMMD/Writer/PMXWriter.cs` | 按 PMX 原始角度语义写入 IK 限制 |
| `Assets/Scripts/Exporters/UnityPMXRuntimeLoader/LibMMD/Reader/PMXReader.cs` | 对称读取 IK 限制,并对异常端点排序 |
| `Assets/Scripts/Exporters/UnityPMXRuntimeLoader/LibMMD/Util/MMDReaderWriteUtil.cs` | 区分空间向量与原始 `float3` 的二进制读写 |
`UnityPMXRuntimeLoader` 目录虽然名称包含 Loader,但其中同时放置了 PMX Reader、Writer 和共享二进制工具。导出器实际调用该目录中的 `PMXWriter`,所以修复 PMX 写出语义必须修改这里;Reader 同步修改用于维持往返一致性。
## 7. 回归验证清单
每次修改 PMX 导出、角色初始化、Animator 控制或骨架映射后,至少执行以下验证:
1. 分别选择一个曾导致姿势污染的动作和一个正常动作,导出同一角色。
2. 在 Blender 的 Rest Position 或等价静止骨架视图中比较两份 PMX,基础姿势应一致。
3. 在 MMD Skin 中加载,确认不崩溃,足 IK 和动作播放正常。
4. 使用另一个独立 PMX 工具加载,例如 Blender mmd_tools、nanoem 或 PMXEditor。
5. 检查左右上臂相对初始化姿势分别为 `-38.5°` 和 `+38.5°`。
6. 检查膝盖 IK 回读值约为 `[-π, -0.0087]`,且逐轴满足下限不大于上限。
7. 检查所有顶点权重、父骨、尾端、IK 目标和 IK 链接索引均有效。
8. 导出后确认预览动作、表情、眼动、动态骨和物理状态恢复。
9. 执行:
```powershell
dotnet build umamusume.csproj -nologo
git diff --check
```
本次修复已完成问题动作、正常动作、Blender 和 MMD Skin 两方面实机验证,结果全部通过。
### 7.1 普通角色单次导出实测(2026-09-26)
- 在隔离的 Unity 2022.3 编辑器中加载无声铃鹿(1002)、服装 `00`,调用现有 `ModelExporter.ExportModel`。
- 首次运行在材质处理抛出 `InvalidCastException`;单独修正材质分支后,表情处理也暴露同一类型假设。根因是收集所有 `Renderer`,却将非 `MeshRenderer` 一律当作 `SkinnedMeshRenderer`。
- 在 `ReadPMXModel` 入口统一保留 `MeshRenderer` 和 `SkinnedMeshRenderer`,使顶点、材质、表情处理共享同一网格渲染器序列;非网格渲染器不参与模型转换。
- 修复后完成真实 PMX 写出及 `PMXReader` 读回:11,110 顶点、23,477 三角形、240 骨骼、214 表情、47 刚体、36 关节。三角形顶点索引检查通过,22 个贴图引用均存在;输出的 23 个 PNG(含完整眼贴图)均通过解码校验。
- 此次验证不包含 Blender/MMD 外部加载、视觉效果或动作播放验证,也不代表全角色批量导出已经实现。
## 8. 禁止回退的实现模式
- 不要从当前 Animator 帧直接读取 PMX 基础骨架。
- 不要只快照 `SkinnedMeshRenderer.bones`。
- 不要使用世界坐标快照恢复完整骨架层级。
- 不要把碰撞体、Handle、Pole、Target 或控制节点无筛选地导出。
- 不要在过滤骨骼后继续使用旧列表位置作为 PMX 索引。
- 不要把角度、位置、方向和颜色仅因为都是 `Vector3` 就交给同一转换函数。
- 不要只为单一加载器交换 IK 上下限;文件本身应首先满足 PMX 语义和通用数值不变量。
- 不要把 `38.5°` 描述成 PMX 规范要求;它是当前角色与 VMD 导出的校准参数。
## 9. 参考资料
- [PMXEditor 0.2.3.6 所附 PMX 规格备份(日文)](https://gist.github.com/FlandreDaisuki/90ae5abf3138a15994526b6bfec73c2c)
- [PMX 2.0 文件格式英文整理](https://gist.github.com/DeXP/16ccdd09841bdc1961e0)
- [nanoem 模型编辑文档](https://nanoem.readthedocs.io/ja/latest/model.html)
- [nanoem 技术架构与 PMX 数据模型](https://nanoem.readthedocs.io/ja/latest/architecture.html)
- [babylon-mmd:PMX、标准骨架与准标准骨架说明](https://noname0310.github.io/babylon-mmd/docs/reference/understanding-mmd-behaviour/introduction-to-pmx-and-pmd/)
- [Blender mmd_tools 文档:Rest Position 与导出注意事项](https://github.com/powroupi/blender_mmd_tools/wiki/Documentation)