【MMD】用python解析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中的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]的角度值,我用程序跑的时候,这四个值完全看不懂;幸好在英文网站上找到这个表示方法:四元数。四元数是用四个值表示旋转的方法
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,读入模型,随意改变一个骨骼点,记录帧,就会发现左下角会出现补间曲线。

  补间曲线的用处,就是自动补齐当前记录帧与上一个记录帧之间动作的变化顺序,曲线斜率越高,动作变化越快,具体教程可以参照贴吧中的教程,我们可以通过拖动红色的小x改变调节线,从而改变曲线
每一组小红x的坐标,就可以唯一确定一条补间曲线,因此,上面的补间曲线存储的就是小红x的坐标
(x_1, y_1, x_2, y_2)
,其中左下角调整线的小红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的文件,包括模型、动作数据等

Github链接
Qiita链接

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

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,189评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,577评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,857评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,703评论 1 276
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,705评论 5 366
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,620评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,995评论 3 396
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,656评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,898评论 1 298
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,639评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,720评论 1 330
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,395评论 4 319
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,982评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,953评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,195评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 44,907评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,472评论 2 342

推荐阅读更多精彩内容