跳转至

本文由 简悦 SimpRead 转码, 原文地址 www.jianshu.com

前言

MikuMikuDance(简称 MMD)是一款动画软件,早期视为 Vocaload 角色制作动画的软件,现在还经常能在 B 站等视频网站,或一些动画网站(某 I 站)看到 MMD 作品。
  我在高中也简单学过操作这款软件以及 PE、水杉等软件,学会了简单 k 帧、套动作、调渲染、加后期、压缩等技术,这与我学习计算机专业有很大的关系(虽然学校学的和这个八竿子打不着,或许我应该学美术去), 现在已经分不清很多东西了,封面静画就是杂七杂八过气 MME 一锅扔的成果,得益于 G 渲的强大,还能看出一点效果。
  现在我想学一些 3D 的开发,包括用程序读取模型、动作等,很快我就想到之前用过的 MMD。
  一些 3D 姿势估计(3D pose estimate)或许能得到骨骼位置以及 PAF(骨骼间关系),但我需要知道 3D 动画是如何储存动作数据的,才能想到怎样将姿势估计得到的数据转化为动作数据。
  因此我找了一些资料解析 MMD 的动作数据 VMD(Vocaload Mation Data)文件,并写下这篇记录。

我的参考文献:
MMD 中的 VMD 文件格式详解国内博客,解释 VMD 格式并用 Java 读取
VMD file formatMMD Wiki

本文会用 python 解析 vmd 文件,并纠正上述文章的一点错误。
  根据 MMD 的规矩,上借物表:

名称来源
MikuMikuDanceE_v803圝龙龍龖龘圝
八重樱神帝宇

封面静画:

名称类别 \ 来源
LightBloom背光
AutoLuminousBasic自发光特效
HgSAO阴影
SoftLightSB柔化
SvSSAO阴影
XDOF景深
dGreenerShaderG 渲
Tokyo Stage场景

一、格式说明

首先,vmd 文件本身是一个二进制文件,里面装着类型不同的数据:uint8、uint32_t、float,甚至还有不同编码的字符串,因此我们需要二进制流读入这个文件。
  vmd 格式很像计算机网络的协议格式,某某位是什么含义,区别是,vmd 文件的长度**理论**上是无限的,让我们来看看。
  vmd 的大致格式如下:

  • 头部
  • 关键帧数量
  • 关键帧

头部

最开始的就是**头部(header)**,看到这就有十分强烈的既视感:

类型长度含义
byte30版本信息
byte10 or 20模型名称

其中,版本信息(VersionInformation)**长度为 30,是 ascii 编码的字符串,翻译过来有两种,一为 “Vocaloid Motion Data file”,二为 “Vocaloid Motion Data 0002”,长度不足 30 后用 0(或者说 b'\x00')填充。这是由于 vmd 版本有两种,大概是为了解决模型名称长度不足,因此后续只影响模型名称的占用长度。
  **模型名称(ModelName)
,是动作数据保存时用的模型的模型名,通过这个我们可以获取到那个名称,我们知道,一个动作数据想要运作起来,只要套用模型的骨骼名称是标准的模板就可以,因此我想象不出这个名称有何用处,或许某些模型带有特殊骨骼,例如翅膀之类的,这样能方便回溯?模型名称的长度根据版本而决定,version1 为 10,version 长度为 20。编码原文写的是 shift-JIS,是日语编码,这样想没错,然而我试验后发现并非如此,例如经常改模型的大神**神帝宇**的模型,他的模型名称用 shift-JIS 为乱码,用 gb2312 竟然能正常读出来;还有**机动牛肉**大神的模型,他的模型名称用 gb2312 无法解码,用 shift-JIS 解码竟然是正常的简体中文???怎么做到的?

骨骼关键帧(BoneKeyFrame)

骨骼关键帧,分为两部分:骨骼关键帧数、骨骼关键帧记录:

类型长度含义
uint32_t4骨骼关键帧数量 BoneKeyFrameNumber
类型长度含义
byte15骨骼名称 BoneName
uint32_t4关键帧时间 FrameTime
float*312x,y,z 空间坐标 Translation.xyz
float*416旋转四元数 x,y,z,w Rotation.xyzw
uint8_t * 16 or uint32 * 416补间曲线 x 的坐标 XCurve
uint8_t * 16 or uint32 * 416补间曲线 y 的坐标 YCurve
uint8_t * 16 or uint32 * 416补间曲线 z 的坐标 ZCurve
uint8_t * 16 or uint32 * 416补间曲线旋转的坐标 RCurve
byte111合计

为何要分开写呢?因为骨骼关键帧数量只需要一个就够了,而后面骨骼关键帧记录的数量会和前面的骨骼关键帧数量保持一致,最后大概是这种效果:

我们可以查一下,每个骨骼关键帧的数量为 111 字节。

旋转坐标

一开始还没发现,旋转坐标竟然有四个,分别为 x, y, z, w,急的我去 MMD 里查看一下,发现和我印象中没有什么差别

都是 [-180, 180] 的角度值,我用程序跑的时候,这四个值完全看不懂;幸好在英文网站上找到这个表示方法:四元数。四元数是用四个值表示旋转的方法

,其中

都是虚数,我上网找了一堆资料,并且得到了四元数转化欧拉角的公式

得到的是角度制,我们通过角度制转弧度制的公式即可算出和 MMD 中等同的角度表示。

补间曲线

为何补间曲线的类型不确定呢?上面 csdn 博客的教程说 “uint8_t 那里有冗余,每四个只读第一个就行”。说的没有问题,首先我们要清楚这个补间曲线坐标的含义。
  我们打开 MMD,读入模型,随意改变一个骨骼点,记录帧,就会发现左下角会出现补间曲线。

补间曲线的用处,就是自动补齐当前记录帧与上一个记录帧之间动作的变化顺序,曲线斜率越高,动作变化越快,具体教程可以参照贴吧中的

教程

,我们可以通过拖动红色的小 x 改变调节线,从而改变曲线

每一组小红 x 的坐标,就可以唯一确定一条补间曲线,因此,上面的补间曲线存储的就是小红 x 的坐标

,其中左下角调整线的小红 x 是看做点 1,通过程序读取,我知道,小红 x 的坐标取值为 [0~127] 间的整数,因此用 1 字节完全可以存下,可能是当时的设计错误,用了 32 位整数存,高 24 位完全浪费了,完全可以不用读取,因此我们可以

直接读取 32 位无符号整数

读取 8 位无符号整数,然后跳过 24 位

如果曲线只有一个,那么为什么会有四个补间曲线呢?实际上不止一个,补间曲线框的右上角就有个下拉菜单可以选择,对于圆形骨骼,没有相对位置变化,x, y, z 补间曲线没有用,只有旋转速率可以调节,而方框骨骼可以移动,因此 x, y, z, 旋转补间曲线都有用处。

回过头来,再说一下补间曲线的坐标,在这里,是以左下角为原点,横纵方向 [0, 127] 的坐标轴

1.png

后面的格式与这个格式大同小异。

表情关键帧(MorphKeyFrame)

表情关键帧分为:表情关键帧数、表情关键帧记录:

类型长度含义
uint32_t4表情关键帧数量 MorphKeyFrameNumber
类型长度含义
byte15表情名称 MorphName
uint32_t4关键帧时间 FrameTime
float4程度 Weight
byte23合计

表情关键帧每个记录长度为 23 字节,其中程度(Weight)是取值为 [0, 1] 之间的浮点数,在 MMD 中的表现如下:

镜头(CameraKeyFrame)

镜头关键帧分为:镜头关键帧数、镜头关键帧记录:

类型长度含义
uint32_t4镜头关键帧数量 CameraKeyFrameNumber
类型长度含义
uint32_t4关键帧时间 FrameTime
float4距离 Distance
float*312x,y,z 空间坐标 Position.xyz
float*312旋转角度(弧度制) Rotation.xyz
uint8_t*2424相机曲线 Curve
uint32_t4镜头 FOV 角度 ViewAngle
uint8_t1Orthographic 相机
byte61合计

距离是我们镜头与中心红点的距离,在 MMD 中,我们可以通过滑轮改变

这有什么用呢?可以看下面的图:

当距离为 0 时,我们的镜头就在红点上,造成的效果是,当我们移动镜头的 Y 角度时,镜头就好像在我们眼睛上,视角是第一人称视角。可以看

这里

,是找镜头资料时偶然看到的。

旋转角度不再是四元数,而是普通的弧度制角度,我猜大概是镜头的万向锁情况没那么严重,因此用弧度制就能表示。

Curve 是曲线的意思,按照之前的的补间曲线,确实还有一个相机曲线,不过一个曲线 = 两个小红 x=4 个坐标点 = 四字节,因此 24 字节有 20 字节的冗余,它的前四个字节就已经表达了坐标,后面 20 个字节是将这 4 个字节重复了 5 次。

镜头 FOV 角度和透视值有关,上面的博客写的是 float,但实际上我试验是 uint32_t,取值刚好就是 MMD 中的透视值。

Orthographic 似乎是一种特殊的相机,没有近大远小的透视关系(不确定),不过在我的实验中,它一直取值为 0。和上面的已透视没有关系,当取消已透视时,透视值会强制为 1。
  下面的骨骼追踪似乎没有记录,可能是强制转换成骨骼所在的坐标了。
  后面的格式与这个格式大同小异。

光线关键帧(LightKeyFrame)

表情关键帧分为:光线关键帧数、光线关键帧记录:

类型长度含义
uint32_t4光线关键帧数量 LightKeyFrameNumber
类型长度含义
uint32_t4关键帧时间 FrameTime
float*312RGB 颜色空间 color.rgb
float*312xyz 投射方向 Direction.xyz
byte28合计

rgb 颜色空间之 [0, 1] 之间的数,类似 html 的 RGB(50%, 20%, 30%)这种表示方法,转换方式就是把 RGB 值分别除以 256。
  光线投射方向是 [-1, 1] 之间的小数。正所对的投射方向是坐标轴的负方向,例如将 Y 拉到 1, 光线会从上向下投影。

二、代码读取

我依旧会使用面向对象的方式构建 VMD 类,不过构造方法无力,属性太多,我选择用静态方法添加属性的方式构建对象

class Vmd:

    def __init__(self):
        pass

    @staticmethod
    def from_file(filename, model_name_encode="shift-JIS"):

        with open(filename, "rb") as f:
            from functools import reduce
            array = bytes(reduce(lambda x, y: x+y, list(f)))

        vmd = Vmd()

        VersionInformation = array[:30].decode("ascii")
        if VersionInformation.startswith("Vocaloid Motion Data file"):
            vision = 1
        elif VersionInformation.startswith("Vocaloid Motion Data 0002"):
            vision = 2
        else:
            raise Exception("unknow vision")

        vmd.vision = vision

        vmd.model_name = array[30: 30+10*vision].split(bytes([0]))[0].decode(model_name_encode)
        vmd.bone_keyframe_number = int.from_bytes(array[30+10*vision: 30+10*vision+4], byteorder='little', signed=False)
        vmd.bone_keyframe_record = []
        vmd.morph_keyframe_record = []
        vmd.camera_keyframe_record = []
        vmd.light_keyframe_record = []

        current_index = 34+10 * vision
        import struct
        for i in range(vmd.bone_keyframe_number):
            vmd.bone_keyframe_record.append({
                "BoneName": array[current_index: current_index+15].split(bytes([0]))[0].decode("shift-JIS"),
                "FrameTime": struct.unpack("<I", array[current_index+15: current_index+19])[0],
                "Position": {"x": struct.unpack("<f", array[current_index+19: current_index+23])[0],
                            "y": struct.unpack("<f", array[current_index+23: current_index+27])[0],
                            "z": struct.unpack("<f", array[current_index+27: current_index+31])[0]
                            },
                "Rotation":{"x": struct.unpack("<f", array[current_index+31: current_index+35])[0],
                            "y": struct.unpack("<f", array[current_index+35: current_index+39])[0],
                            "z": struct.unpack("<f", array[current_index+39: current_index+43])[0],
                            "w": struct.unpack("<f", array[current_index+43: current_index+47])[0]
                            },
                "Curve":{
                    "x":(array[current_index+47], array[current_index+51], array[current_index+55], array[current_index+59]),
                    "y":(array[current_index+63], array[current_index+67], array[current_index+71], array[current_index+75]),
                    "z":(array[current_index+79], array[current_index+83], array[current_index+87], array[current_index+91]),
                    "r":(array[current_index+95], array[current_index+99], array[current_index+103], array[current_index+107])
                }


            })
            current_index += 111

        # vmd['MorphKeyFrameNumber'] = int.from_bytes(array[current_index: current_index+4], byteorder="little", signed=False)
        vmd.morph_keyframe_number = int.from_bytes(array[current_index: current_index+4], byteorder="little", signed=False)
        current_index += 4

        for i in range(vmd.morph_keyframe_number):
            vmd.morph_keyframe_record.append({
                'MorphName': array[current_index: current_index+15].split(bytes([0]))[0].decode("shift-JIS"),
                'FrameTime': struct.unpack("<I", array[current_index+15: current_index+19])[0],
                'Weight': struct.unpack("<f", array[current_index+19: current_index+23])[0]
            })
            current_index += 23

        vmd.camera_keyframe_number = int.from_bytes(array[current_index: current_index+4], byteorder="little", signed=False)
        current_index += 4

        for i in range(vmd.camera_keyframe_number):
            vmd.camera_keyframe_record.append({
                'FrameTime': struct.unpack("<I", array[current_index: current_index+4])[0],
                'Distance': struct.unpack("<f", array[current_index+4: current_index+8])[0],
                "Position": {"x": struct.unpack("<f", array[current_index+8: current_index+12])[0],
                            "y": struct.unpack("<f", array[current_index+12: current_index+16])[0],
                            "z": struct.unpack("<f", array[current_index+16: current_index+20])[0]
                            },
                "Rotation":{"x": struct.unpack("<f", array[current_index+20: current_index+24])[0],
                            "y": struct.unpack("<f", array[current_index+24: current_index+28])[0],
                            "z": struct.unpack("<f", array[current_index+28: current_index+32])[0]
                            },
                "Curve": tuple(b for b in array[current_index+32: current_index+36]),
                "ViewAngle": struct.unpack("<I", array[current_index+56: current_index+60])[0],
                "Orthographic": array[60]
            })
            current_index += 61

        vmd.light_keyframe_number = int.from_bytes(array[current_index: current_index+4], byteorder="little", signed=False)
        current_index += 4

        for i in range(vmd.light_keyframe_number):
            vmd.light_keyframe_record.append({
                'FrameTime': struct.unpack("<I", array[current_index: current_index+4])[0],
                'Color': {
                    'r': struct.unpack("<f", array[current_index+4: current_index+8])[0],
                    'g': struct.unpack("<f", array[current_index+8: current_index+12])[0],
                    'b': struct.unpack("<f", array[current_index+12: current_index+16])[0]
                },
                'Direction':{"x": struct.unpack("<f", array[current_index+16: current_index+20])[0],
                            "y": struct.unpack("<f", array[current_index+20: current_index+24])[0],
                            "z": struct.unpack("<f", array[current_index+24: current_index+28])[0]
                            }
            })
            current_index += 28

        vmd_dict = {}
        vmd_dict['Vision'] = vision
        vmd_dict['ModelName'] = vmd.model_name
        vmd_dict['BoneKeyFrameNumber'] = vmd.bone_keyframe_number
        vmd_dict['BoneKeyFrameRecord'] = vmd.bone_keyframe_record
        vmd_dict['MorphKeyFrameNumber'] = vmd.morph_keyframe_number
        vmd_dict['MorphKeyFrameRecord'] = vmd.morph_keyframe_record
        vmd_dict['CameraKeyFrameNumber'] = vmd.camera_keyframe_number
        vmd_dict['CameraKeyFrameRecord'] = vmd.camera_keyframe_record
        vmd_dict['LightKeyFrameNumber'] = vmd.light_keyframe_number
        vmd_dict['LightKeyFrameRecord'] = vmd.light_keyframe_record

        vmd.dict = vmd_dict

        return vmd

三、实验

随意掰弯一些关节并注册、使用:

if __name__ == '__main__':
    vmd = Vmd.from_file("test.vmd", model_name_encode="gb2312")
    from pprint import pprint
    pprint(vmd.dict)

output:

{'BoneKeyFrameNumber': 4,
 'BoneKeyFrameRecord': [{'BoneName': '右腕',
                         'Curve': {'r': (20, 20, 107, 107),
                                   'x': (20, 20, 107, 107),
                                   'y': (20, 20, 107, 107),
                                   'z': (20, 20, 107, 107)},
                         'FrameTime': 0,
                         'Position': {'x': 0.0, 'y': 0.0, 'z': 0.0},
                         'Rotation': {'w': 0.9358965158462524,
                                      'x': 0.0,
                                      'y': -0.3522740602493286,
                                      'z': 0.0}},
                        {'BoneName': '首',
                         'Curve': {'r': (127, 127, 127, 127),
                                   'x': (0, 127, 0, 127),
                                   'y': (0, 0, 0, 0),
                                   'z': (127, 0, 127, 0)},
                         'FrameTime': 60,
                         'Position': {'x': 0.0, 'y': 0.0, 'z': 0.0},
                         'Rotation': {'w': 0.9191020727157593,
                                      'x': 0.0,
                                      'y': -0.3940184712409973,
                                      'z': 0.0}},
                        {'BoneName': '右ひじ',
                         'Curve': {'r': (127, 127, 127, 127),
                                   'x': (0, 127, 0, 127),
                                   'y': (0, 0, 0, 0),
                                   'z': (127, 0, 127, 0)},
                         'FrameTime': 60,
                         'Position': {'x': 0.0, 'y': 0.0, 'z': 0.0},
                         'Rotation': {'w': 0.9568025469779968,
                                      'x': 0.0,
                                      'y': -0.290740042924881,
                                      'z': 0.0}},
                        {'BoneName': '右腕',
                         'Curve': {'r': (20, 20, 107, 107),
                                   'x': (20, 20, 107, 107),
                                   'y': (20, 20, 107, 107),
                                   'z': (20, 20, 107, 107)},
                         'FrameTime': 60,
                         'Position': {'x': 0.0, 'y': 0.0, 'z': 0.0},
                         'Rotation': {'w': 0.593818187713623,
                                      'x': 0.0,
                                      'y': -0.8045986294746399,
                                      'z': 0.0}}],
 'CameraKeyFrameNumber': 0,
 'CameraKeyFrameRecord': [],
 'LightKeyFrameNumber': 0,
 'LightKeyFrameRecord': [],
 'ModelName': '八重樱',
 'MorphKeyFrameNumber': 2,
 'MorphKeyFrameRecord': [{'FrameTime': 60, 'MorphName': 'まばたき', 'Weight': 1.0},
                         {'FrameTime': 60,
                          'MorphName': 'あ',
                          'Weight': 0.36000001430511475}],
 'Vision': 2}

因为前面提到的编码模式,我选择用 gb2312 解码,在很多(也许是大部分)动作数据都会报错,可以去掉编码方式:

vmd = Vmd.from_file("test.vmd")

我们没有移动方块骨骼,因此位置信息都是 0。
  不喜欢看欧拉角的话,可以写一个转换方法:

@staticmethod
    def _quaternion_to_EulerAngles(x, y, z, w):
        import numpy as np
        X = np.arcsin(2*w*x-2*y*z) / np.pi * 180
        Y = -np.arctan2(2*w*y+2*x*z, 1-2*x**2-2*y**2) / np.pi * 180
        Z = -np.arctan2(2*w*z+2*x*y, 1-2*x**2-2*z**2) / np.pi * 180
        return X, Y, Z

    @property
    def euler_dict(self):
        from copy import deepcopy
        res_dict = deepcopy(self.dict)
        for index, d in enumerate(res_dict['BoneKeyFrameRecord']):
            x = d["Rotation"]["x"]
            y = d["Rotation"]["y"]
            z = d["Rotation"]["z"]
            w = d["Rotation"]["w"]
            X, Y, Z = Vmd._quaternion_to_EulerAngles(x, y, z, w)
            res_dict['BoneKeyFrameRecord'][index]["Rotation"] = {
                "X": X,
                "Y": Y,
                "Z": Z
            }
        return res_dict

这样只要调用:

vmd = Vmd.from_file("test.vmd")
from pprint import pprint
pprint(vmd.euler_dict)

即可得到转换成欧拉角的结果,同样的方式还可以编写转换 RGB、弧度、角度等
  python 内置的 json 包可以很方便得将字典转换成 json 格式文档储存。
  我们也可以试着写一些将 VMD 转换成 vmd 文件的方法。

四、总结

通过学习 VMD 的文件结构,大致了解了储存动作数据的格式和一些方法,或许可以类比到一些主流的商业 3D 软件上。
  读取程序并不难,我写程序的很多时间都是查二进制操作消耗的,通过这个程序,还巩固了二进制操作的知识。
  我在 google 上找到了一个包 saba,专门用于操控 MMD 的文件,包括模型、动作数据等

Github 链接
Qiita 链接

现在学一下图形学,等学有所得再做出更多东西。

评论