动画转换系统中碰到的各种问题

Hime Display 内部实现了 Adobe 的 Mixamo 动画库,以及 MMD 的 vmd 模型动作,vpd 模型姿态到 MMD 模型以及 Vroid 的 VRM 模型的通用匹配,不同模型的骨架不同,不同动画的对应骨架也不同,还存在 IK 这种东西,要实现这样的转换真的相当不容易。

本文是对软件开发中遇到的一些问题的记录,由于我完全没有系统学过计算机图形学,这些东西都是一些个人见解,不一定正确,如果有问题欢迎指出。

本文中放的图并不是很多,主要是因为大多数转换出现问题的截图场面实在是过于……

MMD 动画数据到 MMD

这个事情…MMD 到 MMD 还要转换吗?Three 官方库中已经提供了 MMD 动画的加载,这活不用我干了…

MMD 动画数据到 VRM

前置知识

VRM 这个模型导入到 Three 里面以后结构比较特殊,vrm 本质上是一个 gltf,通过 GLTFLoader 载入以后还需要通过 VRM 进一步加工,提供姿势接口,Mtoon 着色器,物理模拟等功能。 从内部的结构来看,在模型加载完成后会返回一个对象,这里就命名为 vrm 吧,真正添加到渲染场景中的对象是 vrm.scene,这是一个 Tjree 中的 Group,在他的 children 里面,人物的模型并不是一个完整的 SkinnedMesh,而是一个模型 Mesh 的 Group 和一个包含根骨骼的 Group,模型的各个部分以分散的状态存放在 Group 中,和 MMD 不同,骨架和模型的 Mesh 根本就没有父子级关系。 还有一点,VRM 模型也没有使用 IK 骨骼,而对于使用了 IK 的 MMD 模型来讲,vmd 文件中的动画数据当然是 IK 动画数据,因此无法直接套用。

解决方案

想了半天,想出一个叫解决方案,既然你没有一个像 MMD 的 SkinnedMesh,我给你造一个不就行了,搞一个空的 BufferGeometry,配一个基础的 Material,然后加上一个包含 vrm 所有骨骼的骨架,造出一个类似于 MMD 的 SkinnedMesh,而这个 SkinnedMesh,我甚至不需要将它添加到模型的渲染场景中,他就像一个幽灵一样,因此我将其命名为 GhostMesh,为他赋予动画数据,它牵动了骨骼的运动,骨骼带动了了真实存在的 vrm 的 Mesh 运动,这么一想怎么突然有一点恐怖气氛… 至于 IK 问题,我也给骨骼结构上模拟 MMD 的结构造上一套 IK 就行了,Three 中的 IK 通过 CDDIK 算法实现,好处就在于,IK 的存在完全是独立的,添加 IK 解算不会对原始的骨架特性产生任何影响。 接下来就只需要将 MMD 动画轨道中的骨骼命名转换一下,然后就能成功的将动画移植过来了。说的简单,其实一大堆坑:

没有“下半身”骨骼问题

VRM 模型根本就没有“下半身”骨骼,所以下半身直接不会旋转,所以需要手动加上下半身骨骼,然而当我以为这样做就可以了的时候,我发现添加了正确的下半身骨骼后,模型的上半身和腿以及裙子都可以正常转身,但是胯部不会转……

合着胯部不是下半身是吧,这个估计是模型制作的时候胯部顶点数据的权重被加在了更高层次的骨骼上……

解决方案:改!直接把 VRM 模型的骨骼结构改个天翻地覆,让他跟 MMD 长得类似即可。但是这就带来一个问题,模型的骨架都被你打乱了,对他的一些其他控制肯定要出问题啊,这也是为什么 VRM 的动画转换技术上已经搞定,但并没有真正添加的应用内。

这还导致了一个问题,模型在下蹲的时候,胯部也不会动……就像这样:

疯狂抖腿问题

VRM 这里的转换为了简洁起见,我完全没有使用 grant,然而我突然发现,动画轨道里面本应有 IK 管理位置的轨道,例如“左足”“左ひざ”居然有旋转动画轨道……之前播放动画一直有一个问题,就是模型会疯狂抖腿……查半天查不到原因,原来是这几轨动画在和 IK 解算器争夺腿部骨骼的控制权是吧,把你们这几轨动画全删了!

后来经过实际验证,发现出现这个现象还有另一个因素,就是 MMDAnimationHelper 里面在更新 Mesh 动画时的这个操作:

this._restoreBones(mesh);
mixer.update(delta);
this._saveBones(mesh);

关于这个操作,源代码中有注释如下:

/*
 * Avoiding these two issues by restore/save bones before/after mixer animation.
 *
 * 1. PropertyMixer used by AnimationMixer holds cache value in .buffer.
 *    Calculating IK, Grant, and Physics after mixer animation can break
 *    the cache coherency.
 *
 * 2. Applying Grant two or more times without reset the posing breaks model.
 */

不知道内部有什么深层原因,这波为了 grant 而做的存储骨骼操作会导致我这边没用 grant 的模型疯狂抖腿。

最后没办法只有在原始类上扩张一下,把这个函数调整一下了。

Mixamo 动画库到 MMD

前置知识

Mixamo 动画库下载下来的模型格式为 fbx,可以直接下载骨骼动画,也可以选择 With Skin,连带下载一个模型实例。

主要问题:骨架结构有所不同,没有 IK。

动画转换的考虑方案

  1. 给 MMD 重新添加一套 mixamo 骨骼

    这一个操作想要匹配骨骼十分困难,MMD 中的衣服和各种配饰都有自己的骨骼,如果想要通过权重信息重新绑定到 mixamo 骨架上,只能把这些配饰顶点绑定到最近的父级人体骨骼上,这不得大改模型?想想都不太现实,放弃这个方案。

  2. 转换 fbx 的动画数据

    这是最终成功的解决方案

    这个思路就是吧 fbx 的动画数据转换成 MMD 可以读取的动画数据,这样做的话,需要涉及到骨骼动画轨道的名称转换,还要考虑如何解决 MMD 的 IK 问题。

    首先明确一点,一般来讲,一个人物模型的骨骼动画数据只会有一个轨道的位置数据用于控制骨架整体的相对位置(IK 是特例)这一轨动画数据对应的骨骼一般来讲英语叫 Hips,MMD 中这个骨骼叫センター(center),其他全部都是旋转。

    骨骼的在空间中的不同初始大小和位置,会影响关联顶点的运动规则,因此如果骨架太大或太小都会导致模型爆炸。

手臂的 45 度问题:

将两个模型加载进来,你就会意识到一个问题,对于这两种模型,手臂的初始状态是不一样的!MMD 模型的默认状态手臂回稍往下偏,而 Mixamo 的模型手臂是抬平的,当然,动画数据也是匹配手臂抬平的模型的,直接应用到 MMD 模型上后,手臂的旋转肯定对不上。后来通过 GitHub 上的这个帖子open in new window,我得知 MMD 手臂默认是向下偏转 45 度的,因此解决方案是,将每一帧手臂的动画旋转四元数数据转换成欧拉角,然后在 Z 轴方向添加 45 度偏移,再将欧拉角转换回四元数。核心代码如下:

  /**
   * @param {THREE.KeyframeTrack} animationTrack
   * @param {THREE.Euler} eulerAngle
   */
  // 新增函数,对四元数数据附加旋转
  preRotation(animationTrack, eulerAngle) {
    q1.setFromEuler(eulerAngle);
    q1.normalize();
    const armBoneValues = [];
    for (let i = 0; i < animationTrack.times.length; i++) {
      // 对轨道数据进行Z轴旋转变换
      q2.fromArray(animationTrack.values, 4 * i);
      q2.multiply(q1);
      for (const value of q2.toArray()) {
        armBoneValues.push(value);
      }
    }
    animationTrack.values = new Float32Array(armBoneValues);
  }

模型比例不同的问题:

转换动画数据后发现,整个模型直接飞了起来,这个是因为 MMD 模型相对于 Mixamo 上面的模型而言太小了,position 动画数据是直接转移过来的,所以会变得很大。解决方案,计算两个模型大小的比例,把位移轨道的数据整体缩放,试了好久,大概找到一个比较适合用于计算模型大小比例的参数:中心骨骼 position 的 y 值,统一写了一个获取函数如下:

// 获取模型中心位置的高度
function getBaseCenterHeight(obj, type) {
  switch (type) {
    case "mixamo": {
      // mixamo网站导出动画可以选择是否包含Skin,下方这种写法无论是否包含skin都没有问题
      return obj.children.find((bone) => bone.name === "mixamorigHips").position
        .y;
    }
    case "mmd": {
      // mmd模型在腰部到顶级的好几个层级上都可能有高度数据
      return _v1.setFromMatrixPosition(
        getRelativeMatrix(
          obj.skeleton.bones.find((bone) => bone.name === "腰"),
          "全ての親"
        )
      ).y;
    }
    case "vrm": {
      return obj.humanoid.humanBones.hips[0].node.position.y;
    }
  }
}

然后在动画转换的时候添加如下代码:

if (trackType === "position") {
  // 对position动画进行缩放
  track.values.forEach((value, index) => {
    track.values[index] = track.values[index] / positionScale;
  });
}

整个人体上下抖动的问题:

Three 动画里面有个相当阴间的问题,对于一些动画,如果找不到命名对应的骨骼,就会将动画直接绑定到添加到 mixer 的顶级物体上,也就是 MMD 这个 SkinnedMesh 本身,导致模型出现一些奇奇妙妙的抖动。解决方案就是把所有对应不到 MMD 骨骼的动画轨道全部删掉,因此在转换函数里面直接套了一个 Array 的 filter。

IK 匹配问题:

mixamo 没有 IK,但是控制 MMD 的腿部用的是 IK 骨骼,所以直接转换动画数据后,腿根本就不会动,只是跟着整个身体飘来飘去。针对这个问题有两套解决方案:

  1. Three 的 MMD Animation Helper 提供了关闭 IK 的选项,关闭后,的确好了。这里要注意一个问题,涉及到 MMD 内部骨骼的结构,对于腿部骨骼,MMD 模型里面其实有两套,一套直接命名,例如左足,另一套加了一个字母 D 的,例如左足D,腿部顶点的权重真正是绑定到那个带了 D 的骨骼上的。因此直接引用旋转动画时,需要把数据绑定到带有 D 的骨骼上。

  2. 将动画数据硬核转成 IK 数据!基本思路是,通过对 Mixamo 骨架进行旋转模拟,想办法拿到踝关节相对于 Hips 的位置,然后将其应用到 IK 骨骼上。接下来进行具体的介绍,会涉及到一些线性代数知识。

    这个转换的难点就在于,我能得到的原数据只有各种骨骼的旋转数据,现在却需要转换出坐标数据,IK 骨骼在层级结构上是独立于其他骨骼的,它可以在空间中自由移动,现在先来补充一下相对变换与绝对变换的一些知识,模型的所有基础变换,旋转,位移,缩放可以通过一个四维矩阵来表示,各个对象间存在父子关系,child 的变换是在 parent 的基础上完成的,要获取一个对象在世界中的变换,可以将它以及父级链条上所有对象的矩阵相乘,现在相当于是要变换一下层级关系,求这个未知的矩阵,从图里面可以看到它等于这个东西。这里有一个问题,父级的旋转不会为自己带来 position 的改变,但是会导致子级的 position 改变,因为父级旋转时,父级围绕着自己的中心点旋转,子级也是围绕着自己的中心点旋转,而不是自己的中心点,旋转会导致位移变化,因此这样的变换必须要将位置和旋转混会到矩阵中进行变换,否则根本无法算出正确的位置来。

腿部漂移的问题:

两套骨架的中心位置选的不一样,MMD 的センター基本上是在大腿中部,但是Hips在骨盆处,在 MMD 模型中,那里基本上是的位置。如果按照センターHips两者的高度比例缩放动画数据,可以让人物站在地上,但是 xz 方向的位移会偏小,一些动作也会变得不协调,目前想出来的解决方案:将センター直接的位移数据全部缩成 0,这样センター处在同样的位置就行了,之前本来也想过将センター的子级中提出来,但是这样做的话改变了骨架结构不太好。

IK 親的偏移问题:

IK 转换动画后,发现模型向前走时的两腿要么分的很开,想后走时要么直接交叉,这个是因为真正的 IK 骨的父级是 IK 親,它本身和原点就不在一个位置,而且不同的模型所在的位置还有所不同。解决方法,将 IK 親和 IK 骨骼中间的位移全部缩掉

Mixamo 动画库到 VRM

就骨架的本身结构来讲,VRM 做的还是挺好了,很多做针对 MMD 的动画转换操作直接套用过来就行了,这里还不存在 45 度问题/和前面重复的问题就不在此赘述了,关键在于下方这些鬼问题。

模型 Y 轴 180 度翻转问题

当我尝试直接命名骨骼应用动画后,发现模型的双手直接举了起来,然后腿部以一种向前骨折的方式运动,后来推测出来了原因,VRM 模型载入后会有一个很奇怪的情况,模型是背对着你的,这不单单是 Y 轴方向补偿 180 度的问题,这意味着这个骨架在骨骼旋转数据为 0 时,模型是背对着你的… 这使得 XZ 方向的旋转全部都反了,只要明白了这一点,问题的解决方案也就出来了,把这些数据全部调转一下。 当然,最后为了让模型的方向正确,需要为这个 vrm.scene 加上 Y 轴 180 度旋转,注意,这里绝对不能给 Hips 骨骼加上 180 度旋转,虽然静止状态下看不出区别来,但是一旦加上动画,就会将这个变换清除掉,而且这样做了之后,Hips 上的 position 方向也是反的,会导致本该向前运动的模型向后运动(搁这儿走太空步呢…)

后续的补充

手臂子骨骼的 45 度旋转问题

搞到这里又发现一个新的问题,之前遇到的四元数 45 度问题没有这么简单。经过之前的预旋转 45 度的操作,整个手臂的方向倒是转对了,但是手臂子级的旋转方向是错的……

举一个实际的例子,对于其他模型来讲,手臂的默认状态是水平的,因此手肘沿着 Y 轴方向旋转时,划过的区域是一个平面,而对于 MMD 的模型来讲,整个手臂下转 45 度才是默认状态,因此如果手肘在 Y 轴应用旋转,划过的区域会是一个 锥面,显然应用同样的旋转数据得到的手臂姿态是不一样的。

这个问题就不像整个手臂绕 Y 轴旋转 45 度这样就能简单解决了,相当于时整个旋转参考系都变了,思考了一下,发现这种变换不是在欧拉角,而是在四元数下比较好解决。

这里就不得不提一下四元数直接表现出来的一些意义了,一个四元数有四个参数:xyzw,直观的看就是,四元数指定一个物体围绕一个轴的方向旋转一定的角度,而这个转轴的方向其实就是以 xyz 作为坐标的向量的方向,当然,在这里这个 xyz 就不一定是单位向量了,因为对于旋转使用的四元数来讲,xyzw 的平方和是 1,直观体现就是,w 的大小决定了绕定轴旋转多少角度。

反正说来说去,要变换转轴参考系,我只需要把四元数的 xyz 三个参数提取出来作为一个向量,然后对这个向量在保持模长的前提下应用一个适当的旋转,然后再把参数直接放回去就行了。在计算机图形学中,四元数和欧拉角真的起到了很好的互补作用,通过这两种旋转数据的结合应用,就可以很方便的实现各种变换。核心逻辑代码如下:

// 为旋转动画轨道旋转四元数转轴
// 主要用于处理手臂相差45度后,手臂子级旋转错误的问题
/**
 * @param {THREE.KeyframeTrack} quaternionList
 * @param {THREE.Euler} eulerAngle
 */
function trackRotateQuaternionAxis(quaternionList, eulerAngle) {
  const convertedList = [];
  for (let i = 0, l = quaternionList.length / 4; i < l; i++) {
    _v1.fromArray(quaternionList, 4 * i);
    _v1.applyEuler(eulerAngle);
    for (const value of _v1.toArray()) {
      convertedList.push(value);
    }
    convertedList.push(quaternionList[4 * i + 3]);
  }
  return convertedList;
}

效果对比:

前:

后:

Mixamo 兴致一起,重构动画数据,之前的适配全部失效

就在这个软件基本开发完成的时候,我从 Mixamo 网站上新下载了一个模型,结果,载入模型的时候居然爆了,由于场面过于狰狞,我就不放图了。

我顿时感觉前功尽弃,但是后来还是下定决心准备在此解决这个问题。

由于我这边有之前旧的动画数据,我试图找到一个同样的动画的新旧两个版本,对动画轨道的数据差异进行了很多的分析。然后发现,真正引发的变换是,对于那个新动画中的骨架,pose 状态下各个骨骼的欧拉角并不是(0,0,0)了,而在之前,无论是 MMD 模型还是 Mixmao 骨架,在默认姿态下所有的骨骼的旋转角都是 0。我尝试把新版骨架的所有骨骼旋转角复位成(0,0,0),结果发现模型的手举了起来,然后腿被折到了头顶上……

又是经过了好久的思考,我想出了解决这个问题的方法:在动画数据中把新骨架的默认旋转数据抵消掉。

这里会存在一个问题,上级的骨骼旋转数据其实是对下级有影响的,本质上和上方叙述的手臂子骨骼的 45 度旋转问题其实就是一个问题。不过和那个问题不一样的是,这回不单单是一个祖先骨骼会有旋转数据,而是各个祖先都会有旋转数据,这样一来,有需要像之前一样,通过矩阵乘法一层层的往上乘,把绝对变换差计算出来,然后再使用解决上一个问题的那个 trackRotateQuaternionAxis 函数进行一步处理。

其实这个里面涉及到了一些我也尚未正确理解的问题,比如一些层级需要使用转换角的相反数数值进行处理才能得到正确结果,但最终我是想方设法的将正确的计算方式猜出来了,具体实现参考本项目源代码open in new window

Contributors: TSKI433