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

13 KiB
Raw Blame History

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 限制导致加载器崩溃

日志中的关键错误是:

min > max, or either was NaN. min = -0.0087, max = -3.1415927

预期膝盖 X 轴限制为:

lower = -π
upper = -0.0087

旧实现使用通用 WriteVector3 写入 IK 角度。该函数面向空间坐标,会执行模型倍率和 Unity/PMX 坐标轴转换。角度限制不是位置向量,经过同一转换后端点符号和顺序发生变化,文件中出现 lower > upper。

修复后:

  • IK 限制使用专用的原始 float3 读写函数。
  • 写入前拒绝 NaN 和 Infinity。
  • 写入和读取时逐轴规范化上下限。
  • 骨架生成结束后再次校验 IK 目标、链接索引和限制区间。

这里的核心经验是:两个字段即使二进制布局都是 float3,也不代表它们拥有相同的坐标语义。

3.2 预览动作污染基础姿势

旧快照只保存身体主蒙皮引用骨骼的世界位置和世界旋转,而且捕获时机位于模型合并流程中。它没有覆盖:

  • Animator 可能驱动的非蒙皮节点;
  • 控制节点和辅助层级;
  • 局部缩放;
  • 父节点变换对后代节点的影响;
  • 模型合并、身高处理和动画加载之间的初始化时序。

这解释了为什么某些动作导出正常,而某些动作导出后在 Blender 中像被额外套用动作:残留状态取决于该动作实际驱动了哪些 Transform。

修复后,初始化参考姿势在模型合并和身高设置完成、预览动画加载前捕获,并保存完整模型层级的:

localPosition
localRotation
localScale

恢复时同样使用本地变换。这样父子层级按原始局部关系重建,不会因父节点的旋转或缩放产生二次偏移。

3.3 导出过程缺少事务边界

PMX 导出会临时改变 Animator、表情、眼动、动态骨、物理和 Transform。旧流程中的恢复逻辑分散,一旦纹理、模型构建或文件写入中途抛出异常,就可能把角色留在导出状态。

现在由 PMXExportStateSnapshot 统一执行:

捕获当前运行时状态
  -> 冻结 Animator、眼动和物理
  -> 恢复初始化姿势
  -> 应用项目 MMD 参考 A-pose
  -> 构建并写出 PMX
  -> finally 清理临时资源
  -> 恢复表情、完整 Transform 和运行时组件

Dispose() 使用嵌套 finally,保证某一类状态恢复失败时,后续恢复步骤仍会执行。

3.4 Unity 骨架不能直接等同于 MMD 控制骨架

Unity 运行时层级包含蒙皮骨、控制节点、碰撞节点、Handle、Pole、Target 和其他辅助对象。全部导出会造成:

  • 非动画骨进入 PMX;
  • 父子关系不符合 MMD 动作预期;
  • 权重引用不存在或错误的骨骼;
  • 缺少 センター、下半身 和足 IK 等控制层。

新的骨架构建流程从 SkinnedMeshRenderer.bones 收集实际蒙皮引用,再补入动作和父链所需骨骼,并过滤明确的运行时辅助节点。被过滤骨骼的顶点权重会转交给最近的有效祖先,而不是无条件落到索引 0。

4. 当前导出骨架约定

当前体干控制层级为:

全ての親
└─ センター
   └─ グルーブ
      └─ 腰
         ├─ 上半身
         │  └─ 上半身2
         └─ 下半身
            ├─ 左足
            └─ 右足

腿部 IK 控制层级为:

全ての親
├─ 左足IK親
│  └─ 左足IK
│     └─ 左つま先IK
└─ 右足IK親
   └─ 右足IK
      └─ 右つま先IK

足 IK 的目标和链接关系为:

足IK
  target: 足首
  links : ひざ(有限制) -> 足(无限制)

つま先IK
  target: つま先
  links : 足首

膝盖限制使用 PMX 文件坐标语义下的负 X 区间:

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. 执行:
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. 参考资料