fudyou 发表于 2022-6-30 08:28:08

【MMD】解析VMD格式读取

前言

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

根据MMD的规矩,上借物表:

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


封面静画:

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

一、格式说明

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

    头部
    关键帧数量
    关键帧

头部

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

类型
长度 含义
byte 30 版本信息
byte 10 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


类型 长度 含义
byte 15 骨骼名称 BoneName
uint32_t 4 关键帧时间 FrameTime
float*3 12 x,y,z空间坐标 Translation.xyz
float*4 16 旋转四元数x,y,z,w Rotation.xyzw
uint8_t * 16 or uint32 * 4 16补间曲线x的坐标 XCurve

uint8_t * 16 or uint32 * 4 16 补间曲线y的坐标 YCurve
uint8_t * 16 or uint32 * 4 16 补间曲线z的坐标 ZCurve
uint8_t * 16 or uint32 * 4 16 补间曲线旋转的坐标 RCurve
byte 111 合计


为何要分开写呢?因为骨骼关键帧数量只需要一个就够了,而后面骨骼关键帧记录的数量会和前面的骨骼关键帧数量保持一致。
http:////upload-images.jianshu.io/upload_images/7875603-d2ac07d51df401ff.png
我们可以查一下,每个骨骼关键帧的数量为111字节。
旋转坐标

一开始还没发现,旋转坐标竟然有四个,分别为x, y, z, w,急的我去MMD里查看一下,发现和我印象中没有什么差别
data:image/png;base64,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都是[-180, 180]的角度值,我用程序跑的时候,这四个值完全看不懂;幸好在英文网站上找到这个表示方法:四元数。四元数是用四个值表示旋转的方法
w+i·x+j·y+k·z
,其中
i、j、k
都是虚数,我上网找了一堆资料,并且得到了四元数转化欧拉角的公式
\large X = \arcsin {(2wx-2yz)} \\ \large Y = \arctan2 {(2wy+2xz, 1-2x^2-2y^2)} \\ \large Z = \arctan2 {(2wz+2xy, 1-2x^2-2z^2)} \\
得到的是角度制,我们通过角度制转弧度制的公式即可算出和MMD中等同的角度表示。

补间曲线

为何补间曲线的类型不确定呢?上面csdn博客的教程说“uint8_t那里有冗余,每四个只读第一个就行”。说的没有问题,首先我们要清楚这个补间曲线坐标的含义。
我们打开MMD,读入模型,随意改变一个骨骼点,记录帧,就会发现左下角会出现补间曲线。
//upload-images.jianshu.io/upload_images/7875603-9f631407ad18f8ed.png补间曲线的用处,就是自动补齐当前记录帧与上一个记录帧之间动作的变化顺序,曲线斜率越高,动作变化越快,具体教程可以参照贴吧中的教程,我们可以通过拖动红色的小x改变调节线,从而改变曲线
//upload-images.jianshu.io/upload_images/7875603-8cce5fc6170c69cc.png每一组小红x的坐标,就可以唯一确定一条补间曲线,因此,上面的补间曲线存储的就是小红x的坐标
(x_1, y_1, x_2, y_2)
,其中左下角调整线的小红x是看做点1,通过程序读取,我知道,小红x的坐标取值为间的整数,因此用1字节完全可以存下,可能是当时的设计错误,用了32位整数存,高24位完全浪费了,完全可以不用读取,因此我们可以直接读取32位无符号整数或读取8位无符号整数,然后跳过24位。
如果曲线只有一个,那么为什么会有四个补间曲线呢?实际上不止一个,补间曲线框的右上角就有个下拉菜单可以选择,对于圆形骨骼,没有相对位置变化,x, y, z补间曲线没有用,只有旋转速率可以调节,而方框骨骼可以移动,因此x, y, z, 旋转补间曲线都有用处。
//upload-images.jianshu.io/upload_images/7875603-675c5de6b09a0de7.png
回过头来,再说一下补间曲线的坐标,在这里,是以左下角为原点,横纵方向的坐标轴
//upload-images.jianshu.io/upload_images/7875603-0c3edefbec380653.png

后面的格式与这个格式大同小异。
表情关键帧(MorphKeyFrame)

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

类型 长度 含义
uint32_t4
表情关键帧数量 MorphKeyFrameNumber
类型
长度
含义

byte
15
表情名称 MorphName

uint32_t
4
关键帧时间 FrameTime

float
4
程度 Weight

byte
23
合计

表情关键帧每个记录长度为23字节,其中程度(Weight)是取值为之间的浮点数,在MMD中的表现如下:
//upload-images.jianshu.io/upload_images/7875603-fc8f4f3d7e3e134e.png镜头(CameraKeyFrame)

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

类型 长度 含义
uint32_t 4 镜头关键帧数量 CameraKeyFrameNumber


类型 长度 含义
uint32_t 4 关键帧时间 FrameTime
float 4 距离 Distance
float*3 12 x,y,z空间坐标 Position.xyz
float*3 12 旋转角度(弧度制) Rotation.xyz
uint8_t*24 24 相机曲线 Curve
uint32_t 4 镜头FOV角度 ViewAngle
uint8_t 1 Orthographic相机
byte 61 合计
距离是我们镜头与中心红点的距离,在MMD中,我们可以通过滑轮改变
//upload-images.jianshu.io/upload_images/7875603-3f9c3f10831b5146.png
这有什么用呢?可以看下面的图:
//upload-images.jianshu.io/upload_images/7875603-d6ec065edf7b2e2d.png当距离为0时,我们的镜头就在红点上,造成的效果是,当我们移动镜头的Y角度时,镜头就好像在我们眼睛上,视角是第一人称视角。可以看这里,是找镜头资料时偶然看到的。
旋转角度不再是四元数,而是普通的弧度制角度,我猜大概是镜头的万向锁情况没那么严重,因此用弧度制就能表示。
Curve是曲线的意思,按照之前的的补间曲线,确实还有一个相机曲线,不过一个曲线=两个小红x=4个坐标点=四字节,因此24字节有20字节的冗余,它的前四个字节就已经表达了坐标,后面20个字节是将这4个字节重复了5次。
镜头FOV角度和透视值有关,上面的博客写的是float,但实际上我试验是uint32_t,取值刚好就是MMD中的透视值。
//upload-images.jianshu.io/upload_images/7875603-cd3595df5c136039.png
Orthographic似乎是一种特殊的相机,没有近大远小的透视关系(不确定),不过在我的实验中,它一直取值为0。和上面的已透视没有关系,当取消已透视时,透视值会强制为1。
下面的骨骼追踪似乎没有记录,可能是强制转换成骨骼所在的坐标了。
后面的格式与这个格式大同小异。
光线关键帧(LightKeyFrame)

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

类型 长度 含义
uint32_t 4 光线关键帧数量 LightKeyFrameNumber
类型 长度 含义
uint32_t 4 关键帧时间 FrameTime
float*3 12 RGB颜色空间 color.rgb
float*3
12 xyz投射方向 Direction.xyz
byte 28 合计
//upload-images.jianshu.io/upload_images/7875603-3a7deac73f285bb2.pngrgb颜色空间之之间的数,类似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.split(bytes()).decode(model_name_encode)
[*]      vmd.bone_keyframe_number = int.from_bytes(array, 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.split(bytes()).decode("shift-JIS"),
[*]                "FrameTime": struct.unpack("<I", array),
[*]                "Position": {"x": struct.unpack("<f", array),
[*]                            "y": struct.unpack("<f", array),
[*]                            "z": struct.unpack("<f", array)
[*]                            },
[*]                "Rotation":{"x": struct.unpack("<f", array),
[*]                            "y": struct.unpack("<f", array),
[*]                            "z": struct.unpack("<f", array),
[*]                            "w": struct.unpack("<f", array)
[*]                            },
[*]                "Curve":{
[*]                  "x":(array, array, array, array),
[*]                  "y":(array, array, array, array),
[*]                  "z":(array, array, array, array),
[*]                  "r":(array, array, array, array)
[*]                }
[*]
[*]
[*]            })
[*]            current_index += 111
[*]
[*]      # vmd['MorphKeyFrameNumber'] = int.from_bytes(array, byteorder="little", signed=False)
[*]      vmd.morph_keyframe_number = int.from_bytes(array, byteorder="little", signed=False)
[*]      current_index += 4
[*]
[*]      for i in range(vmd.morph_keyframe_number):
[*]            vmd.morph_keyframe_record.append({
[*]                'MorphName': array.split(bytes()).decode("shift-JIS"),
[*]                'FrameTime': struct.unpack("<I", array),
[*]                'Weight': struct.unpack("<f", array)
[*]            })
[*]            current_index += 23
[*]
[*]      vmd.camera_keyframe_number = int.from_bytes(array, 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),
[*]                'Distance': struct.unpack("<f", array),
[*]                "Position": {"x": struct.unpack("<f", array),
[*]                            "y": struct.unpack("<f", array),
[*]                            "z": struct.unpack("<f", array)
[*]                            },
[*]                "Rotation":{"x": struct.unpack("<f", array),
[*]                            "y": struct.unpack("<f", array),
[*]                            "z": struct.unpack("<f", array)
[*]                            },
[*]                "Curve": tuple(b for b in array),
[*]                "ViewAngle": struct.unpack("<I", array),
[*]                "Orthographic": array
[*]            })
[*]            current_index += 61
[*]
[*]      vmd.light_keyframe_number = int.from_bytes(array, 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),
[*]                'Color': {
[*]                  'r': struct.unpack("<f", array),
[*]                  'g': struct.unpack("<f", array),
[*]                  'b': struct.unpack("<f", array)
[*]                },
[*]                'Direction':{"x": struct.unpack("<f", array),
[*]                            "y": struct.unpack("<f", array),
[*]                            "z": struct.unpack("<f", array)
[*]                            }
[*]            })
[*]            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']["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软件上。
读取程序并不难,我写程序的很多时间都是查二进制操作消耗的,通过这个程序,还巩固了二进制操作的知识。


页: [1]
查看完整版本: 【MMD】解析VMD格式读取