本文由 简悦 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 | 景深 |
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_t | 4 | 骨骼关键帧数量 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 | 合计 |
为何要分开写呢?因为骨骼关键帧数量只需要一个就够了,而后面骨骼关键帧记录的数量会和前面的骨骼关键帧数量保持一致,最后大概是这种效果:
我们可以查一下,每个骨骼关键帧的数量为 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_t | 4 | 表情关键帧数量 MorphKeyFrameNumber |
类型 | 长度 | 含义 |
---|---|---|
byte | 15 | 表情名称 MorphName |
uint32_t | 4 | 关键帧时间 FrameTime |
float | 4 | 程度 Weight |
byte | 23 | 合计 |
表情关键帧每个记录长度为 23 字节,其中程度(Weight)是取值为 [0, 1] 之间的浮点数,在 MMD 中的表现如下:
镜头(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 中,我们可以通过滑轮改变
这有什么用呢?可以看下面的图:
当距离为 0 时,我们的镜头就在红点上,造成的效果是,当我们移动镜头的 Y 角度时,镜头就好像在我们眼睛上,视角是第一人称视角。可以看
,是找镜头资料时偶然看到的。
旋转角度不再是四元数,而是普通的弧度制角度,我猜大概是镜头的万向锁情况没那么严重,因此用弧度制就能表示。
Curve 是曲线的意思,按照之前的的补间曲线,确实还有一个相机曲线,不过一个曲线 = 两个小红 x=4 个坐标点 = 四字节,因此 24 字节有 20 字节的冗余,它的前四个字节就已经表达了坐标,后面 20 个字节是将这 4 个字节重复了 5 次。
镜头 FOV 角度和透视值有关,上面的博客写的是 float,但实际上我试验是 uint32_t,取值刚好就是 MMD 中的透视值。
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 | 合计 |
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 的文件,包括模型、动作数据等
现在学一下图形学,等学有所得再做出更多东西。