【MMD】用python解析VMD格式讀取

前言

??MikuMikuDance(簡稱MMD)是一款動畫軟件,早期視為Vocaload角色制作動畫的軟件,現(xiàn)在還經(jīng)常能在B站等視頻網(wǎng)站,或一些動畫網(wǎng)站(某I站)看到MMD作品。
??我在高中也簡單學(xué)過操作這款軟件以及PE、水杉等軟件,學(xué)會了簡單k幀、套動作、調(diào)渲染、加后期、壓縮等技術(shù),這與我學(xué)習(xí)計算機專業(yè)有很大的關(guān)系(雖然學(xué)校學(xué)的和這個八竿子打不著,或許我應(yīng)該學(xué)美術(shù)去),現(xiàn)在已經(jīng)分不清很多東西了,封面靜畫就是雜七雜八過氣MME一鍋扔的成果,得益于G渲的強大,還能看出一點效果。
??現(xiàn)在我想學(xué)一些3D的開發(fā),包括用程序讀取模型、動作等,很快我就想到之前用過的MMD。
??一些3D姿勢估計(3D pose estimate)或許能得到骨骼位置以及PAF(骨骼間關(guān)系),但我需要知道3D動畫是如何儲存動作數(shù)據(jù)的,才能想到怎樣將姿勢估計得到的數(shù)據(jù)轉(zhuǎn)化為動作數(shù)據(jù)。
??因此我找了一些資料解析MMD的動作數(shù)據(jù)VMD(Vocaload Mation Data)文件,并寫下這篇記錄。

我的參考文獻:
MMD中的VMD文件格式詳解國內(nèi)博客,解釋VMD格式并用Java讀取
VMD file formatMMD Wiki

??本文會用python解析vmd文件,并糾正上述文章的一點錯誤。
??根據(jù)MMD的規(guī)矩,上借物表:

名稱 來源
MikuMikuDanceE_v803 圝龍龍龖龘圝
八重櫻 神帝宇

封面靜畫:

名稱 類別\來源
LightBloom 背光
AutoLuminousBasic 自發(fā)光特效
HgSAO 陰影
SoftLightSB 柔化
SvSSAO 陰影
XDOF 景深
dGreenerShader G渲
Tokyo Stage 場景

一、格式說明

??首先,vmd文件本身是一個二進制文件,里面裝著類型不同的數(shù)據(jù):uint8、uint32_t、float,甚至還有不同編碼的字符串,因此我們需要二進制流讀入這個文件。
??vmd格式很像計算機網(wǎng)絡(luò)的協(xié)議格式,某某位是什么含義,區(qū)別是,vmd文件的長度理論上是無限的,讓我們來看看。
??vmd的大致格式如下:

  • 頭部
  • 關(guān)鍵幀數(shù)量
  • 關(guān)鍵幀

頭部

??最開始的就是頭部(header),看到這就有十分強烈的既視感:

類型 長度 含義
byte 30 版本信息
byte 10 or 20 模型名稱

??其中,版本信息(VersionInformation)長度為30,是ascii編碼的字符串,翻譯過來有兩種,一為“Vocaloid Motion Data file”,二為“Vocaloid Motion Data 0002”,長度不足30后用\0(或者說b'\x00')填充。這是由于vmd版本有兩種,大概是為了解決模型名稱長度不足,因此后續(xù)只影響模型名稱的占用長度。
??模型名稱(ModelName),是動作數(shù)據(jù)保存時用的模型的模型名,通過這個我們可以獲取到那個名稱,我們知道,一個動作數(shù)據(jù)想要運作起來,只要套用模型的骨骼名稱是標(biāo)準(zhǔn)的模板就可以,因此我想象不出這個名稱有何用處,或許某些模型帶有特殊骨骼,例如翅膀之類的,這樣能方便回溯?模型名稱的長度根據(jù)版本而決定,version1為10,version長度為20。編碼原文寫的是shift-JIS,是日語編碼,這樣想沒錯,然而我試驗后發(fā)現(xiàn)并非如此,例如經(jīng)常改模型的大神神帝宇的模型,他的模型名稱用shift-JIS為亂碼,用gb2312竟然能正常讀出來;還有機動牛肉大神的模型,他的模型名稱用gb2312無法解碼,用shift-JIS解碼竟然是正常的簡體中文???怎么做到的?

骨骼關(guān)鍵幀(BoneKeyFrame)

??骨骼關(guān)鍵幀,分為兩部分:骨骼關(guān)鍵幀數(shù)、骨骼關(guān)鍵幀記錄:

類型 長度 含義
uint32_t 4 骨骼關(guān)鍵幀數(shù)量 BoneKeyFrameNumber
類型 長度 含義
byte 15 骨骼名稱 BoneName
uint32_t 4 關(guān)鍵幀時間 FrameTime
float*3 12 x,y,z空間坐標(biāo) Translation.xyz
float*4 16 旋轉(zhuǎn)四元數(shù)x,y,z,w Rotation.xyzw
uint8_t * 16 or uint32 * 4 16 補間曲線x的坐標(biāo) XCurve
uint8_t * 16 or uint32 * 4 16 補間曲線y的坐標(biāo) YCurve
uint8_t * 16 or uint32 * 4 16 補間曲線z的坐標(biāo) ZCurve
uint8_t * 16 or uint32 * 4 16 補間曲線旋轉(zhuǎn)的坐標(biāo) RCurve
byte 111 合計

??為何要分開寫呢?因為骨骼關(guān)鍵幀數(shù)量只需要一個就夠了,而后面骨骼關(guān)鍵幀記錄的數(shù)量會和前面的骨骼關(guān)鍵幀數(shù)量保持一致,最后大概是這種效果:

??我們可以查一下,每個骨骼關(guān)鍵幀的數(shù)量為111字節(jié)。

旋轉(zhuǎn)坐標(biāo)

??一開始還沒發(fā)現(xiàn),旋轉(zhuǎn)坐標(biāo)竟然有四個,分別為x, y, z, w,急的我去MMD里查看一下,發(fā)現(xiàn)和我印象中沒有什么差別

都是[-180, 180]的角度值,我用程序跑的時候,這四個值完全看不懂;幸好在英文網(wǎng)站上找到這個表示方法:四元數(shù)。四元數(shù)是用四個值表示旋轉(zhuǎn)的方法
w+i·x+j·y+k·z
,其中
i、j、k
都是虛數(shù),我上網(wǎng)找了一堆資料,并且得到了四元數(shù)轉(zhuǎn)化歐拉角的公式
\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)} \\
得到的是角度制,我們通過角度制轉(zhuǎn)弧度制的公式即可算出和MMD中等同的角度表示。

補間曲線

??為何補間曲線的類型不確定呢?上面csdn博客的教程說“uint8_t那里有冗余,每四個只讀第一個就行”。說的沒有問題,首先我們要清楚這個補間曲線坐標(biāo)的含義。
??我們打開MMD,讀入模型,隨意改變一個骨骼點,記錄幀,就會發(fā)現(xiàn)左下角會出現(xiàn)補間曲線。

??補間曲線的用處,就是自動補齊當(dāng)前記錄幀與上一個記錄幀之間動作的變化順序,曲線斜率越高,動作變化越快,具體教程可以參照貼吧中的教程,我們可以通過拖動紅色的小x改變調(diào)節(jié)線,從而改變曲線
每一組小紅x的坐標(biāo),就可以唯一確定一條補間曲線,因此,上面的補間曲線存儲的就是小紅x的坐標(biāo)
(x_1, y_1, x_2, y_2)
,其中左下角調(diào)整線的小紅x是看做點1,通過程序讀取,我知道,小紅x的坐標(biāo)取值為[0~127]間的整數(shù),因此用1字節(jié)完全可以存下,可能是當(dāng)時的設(shè)計錯誤,用了32位整數(shù)存,高24位完全浪費了,完全可以不用讀取,因此我們可以直接讀取32位無符號整數(shù)讀取8位無符號整數(shù),然后跳過24位
??如果曲線只有一個,那么為什么會有四個補間曲線呢?實際上不止一個,補間曲線框的右上角就有個下拉菜單可以選擇,對于圓形骨骼,沒有相對位置變化,x, y, z補間曲線沒有用,只有旋轉(zhuǎn)速率可以調(diào)節(jié),而方框骨骼可以移動,因此x, y, z, 旋轉(zhuǎn)補間曲線都有用處。

??回過頭來,再說一下補間曲線的坐標(biāo),在這里,是以左下角為原點,橫縱方向[0, 127]的坐標(biāo)軸
1.png

??后面的格式與這個格式大同小異。

表情關(guān)鍵幀(MorphKeyFrame)

??表情關(guān)鍵幀分為:表情關(guān)鍵幀數(shù)、表情關(guān)鍵幀記錄:

類型 長度 含義
uint32_t 4 表情關(guān)鍵幀數(shù)量 MorphKeyFrameNumber
類型 長度 含義
byte 15 表情名稱 MorphName
uint32_t 4 關(guān)鍵幀時間 FrameTime
float 4 程度 Weight
byte 23 合計

??表情關(guān)鍵幀每個記錄長度為23字節(jié),其中程度(Weight)是取值為[0, 1]之間的浮點數(shù),在MMD中的表現(xiàn)如下:

鏡頭(CameraKeyFrame)

??鏡頭關(guān)鍵幀分為:鏡頭關(guān)鍵幀數(shù)、鏡頭關(guān)鍵幀記錄:

類型 長度 含義
uint32_t 4 鏡頭關(guān)鍵幀數(shù)量 CameraKeyFrameNumber
類型 長度 含義
uint32_t 4 關(guān)鍵幀時間 FrameTime
float 4 距離 Distance
float*3 12 x,y,z空間坐標(biāo) Position.xyz
float*3 12 旋轉(zhuǎn)角度(弧度制) Rotation.xyz
uint8_t*24 24 相機曲線 Curve
uint32_t 4 鏡頭FOV角度 ViewAngle
uint8_t 1 Orthographic相機
byte 61 合計

??距離是我們鏡頭與中心紅點的距離,在MMD中,我們可以通過滑輪改變


??這有什么用呢?可以看下面的圖:
當(dāng)距離為0時,我們的鏡頭就在紅點上,造成的效果是,當(dāng)我們移動鏡頭的Y角度時,鏡頭就好像在我們眼睛上,視角是第一人稱視角??梢钥?a target="_blank" rel="nofollow">這里,是找鏡頭資料時偶然看到的。
??旋轉(zhuǎn)角度不再是四元數(shù),而是普通的弧度制角度,我猜大概是鏡頭的萬向鎖情況沒那么嚴(yán)重,因此用弧度制就能表示。
??Curve是曲線的意思,按照之前的的補間曲線,確實還有一個相機曲線,不過一個曲線=兩個小紅x=4個坐標(biāo)點=四字節(jié),因此24字節(jié)有20字節(jié)的冗余,它的前四個字節(jié)就已經(jīng)表達(dá)了坐標(biāo),后面20個字節(jié)是將這4個字節(jié)重復(fù)了5次。
??鏡頭FOV角度和透視值有關(guān),上面的博客寫的是float,但實際上我試驗是uint32_t,取值剛好就是MMD中的透視值。

??Orthographic似乎是一種特殊的相機,沒有近大遠(yuǎn)小的透視關(guān)系(不確定),不過在我的實驗中,它一直取值為0。和上面的已透視沒有關(guān)系,當(dāng)取消已透視時,透視值會強制為1。
??下面的骨骼追蹤似乎沒有記錄,可能是強制轉(zhuǎn)換成骨骼所在的坐標(biāo)了。
??后面的格式與這個格式大同小異。

光線關(guān)鍵幀(LightKeyFrame)

??表情關(guān)鍵幀分為:光線關(guān)鍵幀數(shù)、光線關(guān)鍵幀記錄:

類型 長度 含義
uint32_t 4 光線關(guān)鍵幀數(shù)量 LightKeyFrameNumber
類型 長度 含義
uint32_t 4 關(guān)鍵幀時間 FrameTime
float*3 12 RGB顏色空間 color.rgb
float*3 12 xyz投射方向 Direction.xyz
byte 28 合計

??rgb顏色空間之[0, 1]之間的數(shù),類似html的RGB(50%, 20%, 30%)這種表示方法,轉(zhuǎn)換方式就是把RGB值分別除以256。
??光線投射方向是[-1, 1]之間的小數(shù)。正所對的投射方向是坐標(biāo)軸的負(fù)方向,例如將Y拉到1, 光線會從上向下投影。

二、代碼讀取

??我依舊會使用面向?qū)ο蟮姆绞綐?gòu)建VMD類,不過構(gòu)造方法無力,屬性太多,我選擇用靜態(tài)方法添加屬性的方式構(gòu)建對象

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

三、實驗

??隨意掰彎一些關(guān)節(jié)并注冊、使用:

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解碼,在很多(也許是大部分)動作數(shù)據(jù)都會報錯,可以去掉編碼方式:

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

??我們沒有移動方塊骨骼,因此位置信息都是0。
??不喜歡看歐拉角的話,可以寫一個轉(zhuǎn)換方法:

    @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

??這樣只要調(diào)用:

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

即可得到轉(zhuǎn)換成歐拉角的結(jié)果,同樣的方式還可以編寫轉(zhuǎn)換RGB、弧度、角度等
??python內(nèi)置的json包可以很方便得將字典轉(zhuǎn)換成json格式文檔儲存。
??我們也可以試著寫一些將VMD轉(zhuǎn)換成vmd文件的方法。

四、總結(jié)

??通過學(xué)習(xí)VMD的文件結(jié)構(gòu),大致了解了儲存動作數(shù)據(jù)的格式和一些方法,或許可以類比到一些主流的商業(yè)3D軟件上。
??讀取程序并不難,我寫程序的很多時間都是查二進制操作消耗的,通過這個程序,還鞏固了二進制操作的知識。
??我在google上找到了一個包saba,專門用于操控MMD的文件,包括模型、動作數(shù)據(jù)等

Github鏈接
Qiita鏈接

??現(xiàn)在學(xué)一下圖形學(xué),等學(xué)有所得再做出更多東西。

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容