Skinned Mesh原理解析和一个最简单的实现示例

文章转载自https://blog.csdn.net/n5/article/details/3105872

2018年1月:本文以及demo程序已保存到github上 :
https://github.com/happyfire/SkinnedMeshDemo

作者:n5
Blog: http://blog.csdn.net/n5
2008-10月

       讲述骨骼动画的资料很多,但大部分都是针对DX8或DX9的SkinnedMesh进行讲解。我觉得对于骨骼动画初学者增加了不必要的负担,还没有理解骨骼动画的实质就已被DX复杂的架构搞得晕头转向了。这篇文章把注意力集中在骨骼动画的基本组成结构和原理上,并实现了一个最简单纯手工的自定义骨骼动画例子帮助理解(使用最简单的OpenGL指令,甚至没有使用矩阵)。这篇文章在我学习理解骨骼动画的过程中逐步完善,是对这个技术的理解总结,属于学习笔记。学习过程中参考了很多资料,其中给我启发最大的是Frank Luna写的”Skinned Mesh Character Animation with Direct3D 9.0c”。由于本人自身也是初学者,所以错误和不精确的地方在所难免,欢迎指正和讨论,请发邮件到happyfirecn@yahoo.com.cn或加入3DGameStudy邮件列表:http://happyfire.googlepages.com/3dgamestudymaillist。另外文本不涉及任何高级骨骼动画技术,也不涉及DX架构的SkinnedMesh技术和硬件加速,但本文中会引用SkinnedMesh中的约定俗成的名词,如Transform Matrix,Bone Offset Matrix等。

一、3D模型动画基本原理和分类

       3D模型动画的基本原理是让模型中各顶点的位置随时间变化。主要种类有Morph动画,关节动画和骨骼蒙皮动画(Skinned Mesh)。从动画数据的角度来说,三者一般都采用关键帧技术,即只给出关键帧的数据,其他帧的数据使用插值得到。但由于这三种技术的不同,关键帧的数据是不一样的。Morph(渐变,变形)动画是直接指定动画每一帧的顶点位置,其动画关键中存储的是Mesh所有顶点在关键帧对应时刻的位置。

       关节动画的模型不是一个整体的Mesh,而是分成很多部分(Mesh),通过一个父子层次结构将这些分散的Mesh组织在一起,父Mesh带动其下子Mesh的运动,各Mesh中的顶点坐标定义在自己的坐标系中,这样各个Mesh是作为一个整体参与运动的。动画帧中设置各子Mesh相对于其父Mesh的变换(主要是旋转,当然也可包括移动和缩放),通过子到父,一级级的变换累加(当然从技术上,如果是矩阵操作是累乘)得到该Mesh在整个动画模型所在的坐标空间中的变换(从本文的视角来说就是世界坐标系了,下同),从而确定每个Mesh在世界坐标系中的位置和方向,然后以Mesh为单位渲染即可。关节动画的问题是,各部分Mesh中的顶点是固定在其Mesh坐标系中的,这样在两个Mesh结合处就可能产生裂缝。

       第三类就是骨骼蒙皮动画即Skinned Mesh了,骨骼蒙皮动画的出现解决了关节动画的裂缝问题,而且效果非常酷,发明这个算法的人一定是个天才,因为Skinned Mesh的原理简单的难以置信,而效果却那么好。骨骼动画的基本原理可概括为:在骨骼控制下,通过顶点混合动态计算蒙皮网格的顶点,而骨骼的运动相对于其父骨骼,并由动画关键帧数据驱动。一个骨骼动画通常包括骨骼层次结构数据,网格(Mesh)数据,网格蒙皮数据(skin info)和骨骼的动画(关键帧)数据。下面将具体分析。

二、Skinned Mesh原理和结构分析

       Skinned Mesh中文一般称作骨骼蒙皮动画,正如其名,这种动画中包含骨骼(Bone)和蒙皮(Skinned Mesh)两个部分,Bone的层次结构和关节动画类似,Mesh则和关节动画不同:关节动画中是使用多个分散的Mesh,而Skinned Mesh中Mesh是一个整体,也就是说只有一个Mesh,实际上如果没有骨骼让Mesh运动变形,Mesh就和静态模型一样了。Skinned Mesh技术的精华在于蒙皮,所谓的皮并不是模型的贴图(也许会有人这么想过吧),而是Mesh本身,蒙皮是指将Mesh中的顶点附着(绑定)在骨骼之上,而且每个顶点可以被多个骨骼所控制,这样在关节处的顶点由于同时受到父子骨骼的拉扯而改变位置就消除了裂缝。Skinned Mesh这个词从字面上理解似乎是有皮的模型,哦,如果贴图是皮,那么普通静态模型不也都有吗?所以我觉得应该理解为具有蒙皮信息的Mesh或可当做皮肤用的Mesh,这个皮肤就是Mesh。而为了有皮肤功能,Mesh还需要蒙皮信息,即Skin数据,没有Skin数据就是一个普通的静态Mesh了。Skin数据决定顶点如何绑定到骨骼上。顶点的Skin数据包括顶点受哪些骨骼影响以及这些骨骼影响该顶点时的权重(weight),另外对于每块骨骼还需要骨骼偏移矩阵(BoneOffsetMatrix)用来将顶点从Mesh空间变换到骨骼空间。在本文中,提到骨骼动画中的Mesh特指这个皮肤Mesh,提到模型是指骨骼动画模型整体。骨骼控制蒙皮运动,而骨骼本身的运动呢?当然是动画数据了。每个关键帧中包含时间和骨骼运动信息,运动信息可以用一个矩阵直接表示骨骼新的变换,也可用四元数表示骨骼的旋转,也可以随便自己定义什么只要能让骨骼动就行。除了使用编辑设定好的动画帧数据,也可以使用物理计算对骨骼进行实时控制。

下面分别具体分析骨骼蒙皮动画中的结构部件。

1)理解骨骼和骨骼层次结构(Bone Hierarchy)

       首先要明确一个观念:骨骼决定了模型整体在世界坐标系中的位置和朝向。
       先看看静态模型吧,静态模型没有骨骼,我们在世界坐标系中放置静态模型时,只要指定模型自身坐标系在世界坐标系中的位置和朝向。在骨骼动画中,不是把Mesh直接放到世界坐标系中,Mesh只是作为Skin使用的,是依附于骨骼的,真正决定模型在世界坐标系中的位置和朝向的是骨骼。在渲染静态模型时,由于模型的顶点都是定义在模型坐标系中的,所以各顶点只要经过模型坐标系到世界坐标系的变换后就可进行渲染。而对于骨骼动画,我们设置模型的位置和朝向,实际是在设置根骨骼的位置和朝向,然后根据骨骼层次结构中父子骨骼之间的变换关系计算出各个骨骼的位置和朝向,然后根据骨骼对Mesh中顶点的绑定计算出顶点在世界坐标系中的坐标,从而对顶点进行渲染。要记住,在骨骼动画中,骨骼才是模型主体,Mesh不过是一层皮,一件衣服。
       
如何理解骨骼?请看第二个观念:骨骼可理解为一个坐标空间。

在一些文章中往往会提到关节和骨骼,那么关节是什么?骨骼又是什么?下图是一个手臂的骨骼层次的示例。


       骨骼只是一个形象的说法,实际上骨骼可理解为一个坐标空间,关节可理解为骨骼坐标空间的原点。关节的位置由它在父骨骼坐标空间中的位置描述。上图中有三块骨骼,分别是上臂,前臂和两个手指。Clavicle(锁骨)是一个关节,它是上臂的原点,同样肘关节(elbow joint)是前臂的原点,腕关节(wrist)是手指骨骼的原点。关节既决定了骨骼空间的位置,又是骨骼空间的旋转和缩放中心。为什么用一个4X4矩阵就可以表达一个骨骼,因为4X4矩阵中含有的平移分量决定了关节的位置,旋转和缩放分量决定了骨骼空间的旋转和缩放。我们来看前臂这个骨骼,其原点位置是位于上臂上某处的,对于上臂来说,它知道自己的坐标空间某处(即肘关节所在的位置)有一个子空间,那就是前臂,至于前臂里面是啥就不考虑了。当前臂绕肘关节旋转时,实际是前臂坐标空间在旋转,从而其中包含的子空间也在绕肘关节旋转,在这个例子中是finger骨骼。和实际生物骨骼不同的是,我们这里的骨骼并没有实质的骨头,所以前臂旋转时,他自己没啥可转的,改变的只是坐标空间的朝向。你可以说上图的蓝线在转,但实际蓝线并不存在,蓝线只是画上去表示骨骼之间关系的,真正转的是骨骼空间,我们能看到在转的是wrist joint,也就是两个finger骨骼的坐标空间,因为他们是子空间,会跟随父空间运动,就好比人跟着地球转一样。

       骨骼就是坐标空间,骨骼层次就是嵌套的坐标空间。关节只是描述骨骼的位置即骨骼自己的坐标空间原点在其父空间中的位置,绕关节旋转是指骨骼坐标空间(包括所有子空间)自身的旋转,如此理解足矣。但还有两个可能的疑问,一是骨骼的长度问题,由于骨骼是坐标空间,没有所谓的长度和宽度的限制,我们看到的长度一方面是蒙皮后的结果,另一方面子骨骼的原点(也就是关节)的位置往往决定了视觉上父骨骼的长度,比如这里upper arm线段的长度实际是由elbow joint的位置决定的。第二个问题,手指的那个端点是啥啊?实际上在我们的例子中手指没有子骨骼,所以那个端点并不存在:)那是为了方便演示画上去的。实际问题中总有最下层的骨骼,他们不能决定其他骨骼了,他们的作用只剩下控制Mesh顶点。对了,那么手指的长度如何确定?我们看到的长度应该是由手指部分的顶点和蒙皮决定的,也就是由Mesh中属于手指的那些点离腕关节的距离决定。

       经过一段长篇大论,我们终于清楚骨骼和骨骼层次是啥了,但是为什么要将骨骼组织成层次结构呢?答案是为了做动画方便,设想如果只有一块骨骼,那么让他动起来就太简单了,动画每一帧直接指定他的位置即可。如果是n块呢?通过组成一个层次结构,就可以通过父骨骼控制子骨骼的运动,牵一发而动全身,改变某骨骼时并不需要设置其下子骨骼的位置,子骨骼的位置会通过计算自动得到。上文已经说过,父子骨骼之间的关系可以理解为,子骨骼位于父骨骼的坐标系中。我们知道物体在坐标系中可以做平移变换,以及自身的旋转和缩放变换。子骨骼在父骨骼的坐标系中也可以做这些变换来改变自己在其父骨骼坐标系中的位置和朝向等。那么如何表示呢?由于4X4矩阵可以同时表示上述三种变换,所以一般描述骨骼在其父骨骼坐标系中的变换时使用一个矩阵,也就是DirectX SkinnedMesh中的FrameTransformMatrix。实际上这不是唯一的方法,但应该是公认的方法,因为矩阵不光可以同时表示多种变换还可以方便的通过连乘进行变换的组合,这在层次结构中非常方便。在本文的例子-最简单的skinned mesh实例中,我只演示了平移变换,所以只用一个3d坐标就可以表示子骨骼在父骨骼中的位置。
下面是Bone Class最初的定义:

class Bone
{
public:
       float m_x, m_y, m_z;//这个坐标是定义在父骨骼坐标系中的
};

       OK,除了使用矩阵,坐标或某东西描述子骨骼的位置,我们的Bone Class定义中还需要一些指针来建立层次结构,也就是说我们要能通过父骨骼找到子骨骼或反之。问题是我们需要什么指针呢?从父指向子还是反之?结论是看你需要怎么用了。如果使用矩阵,需要将父子骨骼矩阵级联相乘,无论你的矩阵是左乘列向量还是右乘行向量,从哪边开始乘不重要,只要乘法中父子矩阵的左右位置正确,所以可以在骨骼中只存放指向父的指针,从子到父每次得到父矩阵循环相乘。也可以像DX中那样从根开始相乘并递归。在文本的DEMO中由于没用矩阵,直接使用坐标相加计算坐标,所以要指定父的位置,然后计算出子的位置,那么需要在Bone Class中加入子骨骼的指针,因为子骨骼有n个,所以需要n个指针吗?不一定,看看DirectX的做法,只需要两个就搞定了,指向第一子的和指向兄弟骨骼的。这样事先就不需要知道有多少子了。
下面是修改后的Bone Class:

class Bone
{
    Bone* m_pSibling;
    Bone* m_pFirstChild;
    float m_x, m_y, m_z;//pos in its parent's space 
    float m_wx, m_wy, m_wz; //pos in world space
};

同时增加了一组坐标,存放计算好的世界坐标系坐标。

       将各个骨骼相对于其父骨骼摆放好,就行成了一个骨骼层次结构的初始位置,所谓初始是指定义骨骼层次时,那后来呢?后来动画改变了骨骼的相对位置,准确的说一般是改变了骨骼自身的旋转而位置保持不变(特殊情况总是存在,比如雷曼,可以把拳头扔出去的那个家伙),总之骨骼动了,位置变化了。初始位置很重要,因为通过初始位置骨骼层次间的变换,我们确定了骨骼之间的关系,然后在动画中你可以只用旋转。

       假设我们通过某种方法建立了骨骼层次结构,那么每一块骨骼的位置都依赖于其父骨骼的位置,而根骨骼没有父,他的位置就是整个骨骼体系在世界坐标系中的位置。可以认为root的父就是世界坐标系。但是初始位置时,根骨骼一般不是在世界原点的,比如使用3d max character studio创建的biped骨架时,一般两脚之间是世界原点,而根骨骼-骨盆位于原点上方(+z轴上)。这有什么关系呢?其实也没什么大不了的,只是我们在指定骨骼动画模型整体坐标时,比如设定坐标为(0,0,0),则根骨骼-骨盆被置于世界原点,假如xy平面是地面,那么人下半个身子到地面下了。我们想让两脚之间算作人的原点,这样设定(0,0,0)的坐标时人就站在地面上了,所以可以在两脚之间设定一个额外的根骨骼放在世界原点上,或者这个骨骼并不需要真实存在,只是在你的骨骼模型结构中保存骨盆骨骼到世界原点的变换矩阵。在微软X文件中,一般有一个Scene_Root节点,这算一个额外的骨骼吧,他的变换矩阵为单位阵,表示他初始位于世界原点,而真正骨骼的根Bip01,作为Scene_root的子骨骼,其变换矩阵表示相对于root的位置。说这么多其实我只是想解释下,为什么要存在Scene_Root这种额外的骨骼,以及加深理解骨骼定位骨骼动画模型整体的世界坐标的作用。

有了骨骼类,现在让我们看一下建立骨骼层次的代码,在bone class中增加一个构造函数和两个成员函数:

class Bone

{
public:
      Bone(float x, float y, float z)
          :m_pSibling(NULL),m_pFirstChild(NULL),m_pFather(NULL),
           m_x(x),m_y(y),m_z(z){}

      void SetFirstChild(Bone* pChild)
      {
          m_pFirstChild = pChild; m_pFirstChild->m_pFather = this;
      }

      void SetSibling(Bone* pSibling)
      {
          m_pSibling = pSibling; m_pSibling->m_pFather = m_pFather;
      }
};

注意我增加了一个成员变量,Bone* m_pFather,这是指向父骨骼的指针,在这个例子中计算骨骼动画时本不需要这个指针,但我为了画一条从父骨骼关节到子骨骼关节的连线,增加了它,因为每个骨骼只有第一子骨骼的指针,绘制父骨骼时从父到子画线就只能画一条,所以记录每个骨骼的父,在绘制子骨骼时画这根线。

有了这个函数,就可以创建骨骼层次了,例如:

Bone* g_boneRoot;
Bone* g_bone1, *g_bone21, *g_bone22;

void buildBones()
{
    g_boneRoot = new Bone(0, 0, 0);

    g_bone1 = new Bone(0.1, 0, 0);

    g_bone21 = new Bone(0.0, 0.1, 0);
    g_bone22 = new Bone(0.1, 0.0, 0);

    g_boneRoot->SetFirstChild(g_bone1);
    g_bone1->SetFirstChild(g_bone21);
    g_bone21->SetSibling(g_bone22);
}

       接下来是骨骼层次中最核心的部分,更新骨骼!由于动画的作用,某个骨骼的变换(TransformMatrix)变了,这时就要根据新的变换来计算,所以这个过程一般称作UpdateBoneMatrix。因为骨骼的变换都是相对父的,要变换顶点必须使用世界变换矩阵,所以这个过程是根据更新了的某些骨骼的骨骼变换矩阵(TransformMatrix)计算出所有骨骼的世界变换矩阵(也即CombinedMatrix)。在本文的例子中,骨骼只能平移,甚至我们没有用矩阵,所以当有骨骼变动时要做的只是直接计算骨骼的世界坐标,因此函数命名为ComputeWorldPos,相当于UpdateBoneMatrix后再用顶点乘以CombinedMatrix。

class Bone
{
    //give father's world pos, compute the bone's world pos
    void ComputeWorldPos(float fatherWX, float fatherWY, float fatherWZ)
    {
        m_wx = fatherWX+m_x;
        m_wy = fatherWY+m_y;
        m_wz = fatherWZ+m_z;

        if(m_pSibling!=NULL)
           m_pSibling->ComputeWorldPos(fatherWX, fatherWY, fatherWZ);

        if(m_pFirstChild!=NULL)
           m_pFirstChild->ComputeWorldPos(m_wx, m_wy, m_wz);
     }
};

其中的递归调用使用了微软例子的思想。

       有了上述函数,当某骨骼运动时就可以让其子骨骼跟随运动了,但是怎么让骨骼运动呢?这就是动画问题了。我不打算在这个简单的例子中使用关键帧动画,而只是通过程序每帧改变某些骨骼的位置,DEMO中animateBones就是做这个的,你可以在里面改变不同的骨骼看看效果。在本文下面会对骨骼的关键帧动画做简单的讨论。

       至此,我们定义了骨骼类的结构,手工创建了骨骼层次(实际引擎应该从文件读入),并且可以根据新位置更新骨骼了(实际引擎应该从动画数据读入新的变换或使用物理计算),这样假如我们用连线将骨骼画出来,并且让某个骨骼动起来,我们就会看见他下面的子骨骼跟着动了。当然只有骨骼是不够的,我们要让Mesh跟随骨骼运动,下面就是蒙皮了。

2)蒙皮信息和蒙皮过程
2-1)Skin info的定义

       上文曾讨论过,Skinned Mesh中Mesh是作为皮肤使用,蒙在骨骼之上的。为了让普通的Mesh具有蒙皮的功能,必须添加蒙皮信息,即Skin info。我们知道Mesh是由顶点构成的,建模时顶点是定义在模型自身坐标系的,即相对于Mesh原点的,而骨骼动画中决定模型顶点最终世界坐标的是骨骼,所以要让骨骼决定顶点的世界坐标,这就要将顶点和骨骼联系起来,Skin info正是起了这个作用。
下面是DEMO中顶点类的定义的代码片段:

#define MAX_BONE_PER_VERTEX 4

class Vertex
{
    float m_x, m_y, m_z; //local pos in mesh space
    float m_wX, m_wY, m_wZ;//blended vertex pos, in world space
 
    //skin info
    int m_boneNum;
    Bone* m_bones[MAX_BONE_PER_VERTEX];
    float m_boneWeights[MAX_BONE_PER_VERTEX];
};

       顶点的Skin info包含影响该顶点的骨骼数目,指向这些骨骼的指针,这些骨骼作用于该顶点的权重(Skin weight)。由于只是一个简单的例子,这儿没有考虑优化,所以用静态数组存放骨骼指针和权重,且实际引擎中Skin info的定义方式不一定是这样的,但基本原理一致。

       MAX_BONE_PER_VERTEX在这儿用来设置可同时影响顶点的最大骨骼数,实际上由于这个DEMO是手工进行Vertex Blending并且也没用硬件加速,可影响顶点的骨骼数量并没有限制,只是恰好需要一个常量来定义数组,所以定义了一下。在实际引擎中由于要使用硬件加速,以及为了确保速度,一般会定义最大骨骼数。另外在本DEMO中,Skin info是手工设定的,而在实际项目中,一般是在建模软件中生成这些信息并导出。

       Skin info的作用是使用各个骨骼的变换矩阵对顶点进行变换并乘以权重,这样某块骨骼只能对该顶点产生部分影响。各骨骼权重之和应该为1。

       Skin info是针对顶点的,然而在使用Skin info前我们必须要使用Bone Offset Matrix对顶点进行变换,下面具体讨论Bone offset Matrix。(写下这句话的时候我感觉有些不妥,因为实际是先将所有的矩阵相乘最后再作用于顶点,这儿是按照理论上的顺序进行讲述吧,请不要与实际情况混淆,其实他们也并不矛盾。而且在我们的DEMO中由于没有使用矩阵,所以变换的顺序和理论顺序是一致的)

2-2)Bone Offset Matrix的含义和计算方法

       上文已经说过:“骨骼动画中决定模型顶点最终世界坐标的是骨骼,所以要让骨骼决定顶点的世界坐标”,现在让我们看下顶点受一块骨骼的作用时的坐标变换过程:

mesh vertex (defined in mesh space)---<BoneOffsetMatrix>--->Bone space
---<BoneCombinedTransformMatrix>--->World

       从这个过程中可看出,需要首先将模型顶点从模型空间变换到某块骨骼自身的骨骼空间,然后才能利用骨骼的世界变换计算顶点的世界坐标。Bone Offset Matrix的作用正是将模型从顶点空间变换到骨骼空间。那么Bone Offset Matrix如何得到呢?
下面具体分析:
       Mesh space是建模时使用的空间,mesh中顶点的位置相对于这个空间的原点定义。比如在3d max中建模时(视xy平面为地面,+z朝上),可将模型两脚之间的中点作为Mesh空间的原点,并将其放置在世界原点,这样左脚上某一顶点坐标是(10,10,2),右脚上对称的一点坐标是(-10,10,2),头顶上某一顶点的坐标是(0,0,170)。由于此时Mesh空间和世界空间重合,上述坐标既在Mesh空间也在世界空间,换句话说,此时实际是以世界空间作为Mesh空间了。在骨骼动画中,在世界中放置的是骨骼而不是Mesh,所以这个区别并不重要。在3d max中添加骨骼的时候,也是将骨骼放入世界空间中,并调整骨骼的相对位置使得和mesh相吻合(即设置骨骼的TransformMatrix),得到骨架的初始姿势以及相应的Transform Matrix(按惯例模型做成两臂侧平举直立,骨骼也要适合这个姿态)。由于骨骼的Transform Matrix(作用是将顶点从骨骼空间变换到上层空间)是基于其父骨骼空间的,只有根骨骼的Transform是基于世界空间的,所以要通过自下而上一层层Transform变换(如果使用行向量右乘矩阵,这个Transform的累积过程就是C=MboneMfatherMgrandpar...Mroot),得到该骨骼在世界空间上的变换矩阵 - Combined Transform Matrix,即通过这个矩阵可将顶点从骨骼空间变换到世界空间。那么这个矩阵的逆矩阵就可以将世界空间中的顶点变换到某块骨骼的骨骼空间。由于Mesh实际上就是定义在世界空间了,所以这个逆矩阵就是Offset Matrix。即OffsetMatrix就是骨骼在初始位置(没有经过任何动画改变)时将bone变换到世界空间的矩阵(CombinedTransformMatrix)的逆矩阵,有一些资料称之为InverseMatrix。在几何流水线中,是通过变换矩阵将顶点变换到上层空间,最终得到世界坐标,逆矩阵则做相反的事,所以Inverse这种提法也符合惯例。那么Offset这种提法从字面上怎么理解呢?Offset即骨骼相对于世界原点的偏移,世界原点加上这个偏移就变成骨骼空间的原点,同样定义在世界空间中的点经过这个偏移矩阵的作用也被变换到骨骼空间了。从另一角度理解,在动画中模型中顶点的位置是根据骨骼位置动态计算的,也就是说顶点跟着骨骼动,但首先必须确定顶点和骨骼之间的相对位置(即顶点在该骨骼坐标系中的位置),一个骨骼可能对应很多顶点,如果要保存这个相对位置每个顶点对于每块受控制的骨骼都要保存,这样就要保存太多的矩阵了。。。所以只保存mesh空间到骨骼空间的变换(即OffsetMatrix),然后通过这个变换计算每个顶点在该骨骼空间中的坐标,所以OffsetMatrix也反应了mesh和每块骨骼的相对位置,只是这个位置是间接的通过和世界坐标空间的关系表达的,在初始位置将骨骼按照模型的形状摆好是关键之处。

       以上的分析是通过将mesh space和world space重合得到Offset Matrix的计算方法。那么如果他们不重合呢?那就要先计算顶点从mesh space变换到world space的变换矩阵,并乘上(还是右乘为例)Combined Matrix的Inverse Matrix从而得到Offset Matrix。但是这不是找麻烦吗?因为Mesh的原点在哪儿并不重要,为啥不让他们重合呢?

       还有一个问题是,既然Offset Matrix可以计算出来,为啥还要在骨骼动画文件中同时提供TransformMatrix和OffsetMatrix呢?实际上文件中确实可以不提供OffsetMatrix,而只在载入时计算。但TransformMatrix不可缺少,动画关键帧数据一般只存储骨骼的旋转和根骨骼的位置,骨骼间的相对位置还是要靠TransformMatrix提供。在微软的X文件结构中提供了OffsetMatrix,原因是什么呢?我不知道。我猜想一个可能的原因是为了兼容性和灵活性,比如mesh并没有定义在世界坐标系,而是作为一个object放置在3d max中,在导出骨骼动画时不能简单的认为mesh的顶点坐标是相对于世界原点的,还要把这个object的位置考虑进去,于是导出插件要计算出OffsetMatrix并保存在x文件中以避免兼容性问题。

关于OffsetMatrix和TransformMatrix含有平移,旋转和缩放的讨论:
       首先,OffsetMatrix取决于骨骼的初始位置(即TransformMatrix),由于骨骼动画中我们使用的是动画中的位置,初始位置是什么样并不重要,所以可以在初始位置中只包含平移,而旋转和缩放在动画中设置(一般也仅仅使用旋转,这也是为啥动画通常中可以用一个四元数表示骨骼的关键帧)。在这种情况下,OffsetMatrix只包含平移即可。因此一些引擎的Bone中不存放Transform矩阵,而只存放骨骼在父骨骼空间中的坐标,然后旋转只在动画帧中设置,最基本的骨骼动画即可实现。但也可在Transform和Offset Matrix中包括旋转和缩放,这样可以提高创建动画时的容错性。

在本文DEMO中,我们也没有使用矩阵保存Bone Offset,而只用了一个坐标保存偏移位置。

class BoneOffset
{
public:
    float m_offx, m_offy, m_offz;
};

在Bone class中,有一个方法用来计算Bone Offset

class Bone
{
public:
    BoneOffset m_boneOffset;

    //called after ComputeWorldPos() when bone loaded but not animated
    void ComputeBoneOffset()
    {
       m_boneOffset.m_offx = -m_wx;
       m_boneOffset.m_offy = -m_wy;
       m_boneOffset.m_offz = -m_wz;
       if(m_pSibling!=NULL)
          m_pSibling->ComputeBoneOffset();

       if(m_pFirstChild!=NULL)
          m_pFirstChild->ComputeBoneOffset();
    }
};

       在ComputeBoneOffset()中,使用计算好的骨骼的世界坐标来计算bone offset,这儿的计算只是取一个负数,在实际引擎中,如果bone offset是一个矩阵,这儿就应该是求逆矩阵,其实由于旋转矩阵是正交的,只要求出旋转矩阵的转置矩阵,并将平移部分取反即可(sorry,这儿错了,平移部分不是简单的取负数,推导一下应该是-dot(R,T)),本文不做讨论了。注意由于我们计算Bone offset时是使用计算好的世界坐标,所以在这之前必须在初始位置时对根骨骼调用ComputeWorldPos()以计算出各个骨骼在初始位置时的世界坐标。

2-3)最终:顶点混合(vertex blending)

       现在我们有了Skin info,有了Bone offset,可谓万事具备,只欠东风了。现在就可以做顶点混合了,这是骨骼动画的精髓所在,正是这个技术消除了关节处的裂缝。顶点混合后得到了顶点新的世界坐标,对所有的顶点执行vertex blending后,从Mesh的角度看,Mesh deform(变形)了,变成动画需要的形状了。

首先,让我们看看使用单块骨骼对顶点进行作用的过程,以下是DEMO中的相关代码:

class Vertex
{
public:
    void ComputeWorldPosByBone(Bone* pBone, float& outX, float& outY, float& outZ)
    {
       //step1: transform vertex from mesh space to bone space
       outX = m_x+pBone->m_boneOffset.m_offx;
       outY = m_y+pBone->m_boneOffset.m_offy;
       outZ = m_z+pBone->m_boneOffset.m_offz;
 
       //step2: transform vertex from bone space to world sapce
       outX += pBone->m_wx;
       outY += pBone->m_wy;
       outZ += pBone->m_wz;
    }
};

       这个函数使用一块骨骼对顶点进行变换,将顶点从Mesh坐标系变换到世界坐标系,这儿使用了骨骼的Bone Offset Matrix和 Combined Transform Matrix (嗯,我知道这儿没用矩阵,但意思是一样的对吗)

       对于多块骨骼,对每块骨骼执行这个过程并将结果根据权重混合(即vertex blending)就得到顶点最终的世界坐标。进行vertex blending的代码如下:

class Vertex
{
    void BlendVertex()
    {
       //do the vertex blending,get the vertex's pos in world space
       m_wX = 0;
       m_wY = 0;
       m_wZ = 0;
 
       for(int i=0; i<m_boneNum; ++i)
       {
           float tx, ty, tz;

           ComputeWorldPosByBone(m_bones[i], tx, ty, tz);

           tx*= m_boneWeights[i];
           ty*= m_boneWeights[i];
           tz*= m_boneWeights[i];

           m_wX += tx;
           m_wY += ty;
           m_wZ += tz;
       }
    }
};

       这些函数我都放在Vertex类中了,因为只是一个简单DEMO所以没有特别考虑类结构问题,在BlendVertex()中,遍历影响该顶点的所有骨骼,用每块骨骼计算出顶点的世界坐标,然后使用Skin Weight对这些坐标进行加权平均。tx,ty,tz是某块骨骼作用后顶点的世界坐标乘以权重后的值,这些值相加后就是最终的世界坐标了。

现在让我们用一个公式回顾一下Vertex blending的整个过程(使用矩阵变换)

Vworld = Vmesh * BoneOffsetMatrix1 * CombindMatrix1 * Weight1
+ Vmesh* BoneOffsetMatrix2 * CombinedMatrix2 * Weight2
+ …
+ Vmesh * BoneOffsetMatrixN * CombindMatrixN * WeightN

(这个公式使用的是行向量左乘矩阵)
由于BoneOffsetMatrix和Combined Matrix都是矩阵,可以先相乘这样就减少很多计算了,在实际PC游戏中可以使用VS进行硬件加速计算。

3)动画数据和播放动画

       正如前面所说,本例子中并没有使用动画数据,但动画数据在骨骼动画中确实最重要的,因为我们的最终目的就是播放动画。所以作为DEMO的补充,这儿简要讨论一下动画数据相关问题。其实我觉得动画的处理在骨骼动画中是很灵活的,需要专门的一篇文章讨论。

       本文的最开始说,3D模型动画的基本原理是让模型中各顶点的位置随时间变化。骨骼动画的情况是,骨骼的位置随时间变化,顶点位置随骨骼变化。所以动画数据中必然包含的是骨骼的运动信息。可以在动画帧中包含某时刻骨骼的Transform Matrix,但骨骼一般只是做旋转,所以也可以用一个四元数表示。但有时候骨骼层次整体会在动画中进行平移,所以可能需要在动画帧中包含根骨骼的位置信息。播放动画时,给出当前播放的时间值,对于每块需要动画的骨骼,根据这个值找出该骨骼前后两个关键帧,根据时间差进行插值,对于四元数要使用四元数球面线性插值。然后将插值得到的四元数转换成Transform Matrix,再调用UpdateBoneMatrix(其含义上文已介绍)更新计算整个骨骼层次的CombinedMatrix。

4)总结

       从结构上看,SkinnedMesh包括:动画数据,骨骼数据,包含Skin info的Mesh数据,以及Bone Offset Matrix。

       从过程上看,载入阶段:载入并建立骨骼层次结构,计算或载入Bone Offset Matrix,载入Mesh数据和Skin info(具体的实现不同的引擎中可能都不一样)。运行阶段:根据时间从动画数据中获取骨骼当前时刻的Transform Matrix,调用UpdateBoneMatrix计算出各骨骼的CombinedMatrix,对于每个顶点根据Skin info进行Vertex Blending计算出顶点的世界坐标,最终进行模型的渲染。

三、关于本文的例子

       这个例子做了尽可能的简化,只包含一个cpp文件,使用OpenGL和GLUT作为渲染器和框架,仅有400多行代码。例子中手工创建了一个骨骼层次和Mesh,手工设置Skin info并自动计算BoneOffset,使用程序控制骨骼平移演示了骨骼层次的运动和骨骼影响下Mesh顶点的运动,例子中甚至没有使用矩阵。本例子仅作理解骨骼动画之用。

       截图中绿色网格是模型原始形状,蓝色是骨骼,红色是动画时的模型形状。DEMO中左数第二个骨骼做上下运动,最下方的骨骼做x方向平移。DEMO没有使用旋转,而实际的骨骼动画中往往是没有平移只有旋转的,因为胳膊只能转不能变长,但原理一致。

代码的执行过程为,初始化时:

buildBones();//创建骨骼层次
buildMesh(); //创建Mesh,设置Skin info,计算Bone offset

每帧运行时:

//draw original mesh
g_mesh->DrawStaticMesh(0,0,0);

//move bones
animateBones();

//update all bone's pos in bone tree
g_boneRoot->ComputeWorldPos(0, 0, 0);

//update vertex pos by bones, using vertex blending
g_mesh->UpdateVertices();  

//draw deformed mesh
g_mesh->Draw();

//draw bone
g_boneRoot->Draw();

为确保本文的完整性,下面贴出所有代码。

//  A simplest Skinned Mesh demo, written by n5, 2008.10,
//  My email:happyfirecn@yahoo.com.cn
//  My blog: http://blog.csdn.net/n5

#include <GL/glut.h>

#define NULL 0
//------------------------------------------------------------
class BoneOffset
{
public:
    //BoneOffset transform a vertex from mesh space to bone space.
    //In other words, it is the offset from mesh space to a bone's space.
    //For each bone, there is a BoneOffest.
    //If we add the offset to the vertex's pos (in mesh space), we get the vertex's pos in bone space
    //For example: if a vertex's pos in mesh space is (100,0,0), the bone offset is (-20,0,0), so the vertex's pos in bone space is (80,0,0)
    //Actually, BoneOffset is the invert transform of that we place a bone in mesh space, that is (-20,0,0) means the bone is at (20,0,0) in mesh space

    float m_offx, m_offy, m_offz;
};
class Bone
{
public:
    Bone() {}
    Bone(float x, float y, float z):m_pSibling(NULL),m_pFirstChild(NULL),m_pFather(NULL),m_x(x),m_y(y),m_z(z){}
    ~Bone() {}

    Bone* m_pSibling;
    Bone* m_pFirstChild;
    Bone* m_pFather; //only for draw bone

    void SetFirstChild(Bone* pChild) { m_pFirstChild = pChild; m_pFirstChild->m_pFather = this; }
    void SetSibling(Bone* pSibling) { m_pSibling = pSibling; m_pSibling->m_pFather = m_pFather; }

    float m_x, m_y, m_z;//pos in its parent's space 
    float m_wx, m_wy, m_wz; //pos in world space

    //give father's world pos, compute the bone's world pos
    void ComputeWorldPos(float fatherWX, float fatherWY, float fatherWZ)
    {
       m_wx = fatherWX+m_x;
       m_wy = fatherWY+m_y;
       m_wz = fatherWZ+m_z;

       if(m_pSibling!=NULL)
          m_pSibling->ComputeWorldPos(fatherWX, fatherWY, fatherWZ);

       if(m_pFirstChild!=NULL)
          m_pFirstChild->ComputeWorldPos(m_wx, m_wy, m_wz);
    }

    BoneOffset m_boneOffset;

    //called after compute world pos when bone loaded but not animated
    void ComputeBoneOffset()
    {
       m_boneOffset.m_offx = -m_wx;
       m_boneOffset.m_offy = -m_wy;
       m_boneOffset.m_offz = -m_wz;

       if(m_pSibling!=NULL)
          m_pSibling->ComputeBoneOffset();

       if(m_pFirstChild!=NULL)
          m_pFirstChild->ComputeBoneOffset();
    }
 
    void Draw()
    {
       glColor3f(0,0,1.0);
       glPointSize(4);
       glBegin(GL_POINTS);     
       glVertex3f(m_wx,m_wy,m_wz);
       glEnd();

       if(m_pFather!=NULL)
       {
           glBegin(GL_LINES);                       
           glVertex3f(m_pFather->m_wx,m_pFather->m_wy,m_pFather->m_wz);
           glVertex3f(m_wx,m_wy,m_wz);
           glEnd();
       }
 
       if(m_pSibling!=NULL)
           m_pSibling->Draw();

       if(m_pFirstChild!=NULL)
           m_pFirstChild->Draw(); 
    }
};
#define MAX_BONE_PER_VERTEX 4

class Vertex
{
public:
    Vertex():m_boneNum(0)
    {
    }

    void ComputeWorldPosByBone(Bone* pBone, float& outX, float& outY, float& outZ)
    {
       //step1: transform vertex from mesh space to bone space
       outX = m_x+pBone->m_boneOffset.m_offx;
       outY = m_y+pBone->m_boneOffset.m_offy;
       outZ = m_z+pBone->m_boneOffset.m_offz;
 
       //step2: transform vertex from bone space to world sapce
       outX += pBone->m_wx;
       outY += pBone->m_wy;
       outZ += pBone->m_wz;
    }

    void BlendVertex()
    {
       //do the vertex blending,get the vertex's pos in world space
       m_wX = 0;
       m_wY = 0;
       m_wZ = 0;
 
       for(int i=0; i<m_boneNum; ++i)
       {
           float tx, ty, tz;

           ComputeWorldPosByBone(m_bones[i], tx, ty, tz);

           tx*= m_boneWeights[i];
           ty*= m_boneWeights[i];
           tz*= m_boneWeights[i];

           m_wX += tx;
           m_wY += ty;
           m_wZ += tz;
       }
    }

    float m_x, m_y, m_z; //local pos in mesh space 
    float m_wX, m_wY, m_wZ;//blended vertex pos, in world space

    //skin info
    int m_boneNum;
    Bone* m_bones[MAX_BONE_PER_VERTEX];
    float m_boneWeights[MAX_BONE_PER_VERTEX];

    void SetBoneAndWeight(int index, Bone* pBone, float weight)
    {
       m_bones[index] = pBone;
       m_boneWeights[index] = weight;    
    }
};
class SkinMesh
{
public:
    SkinMesh():m_vertexNum(0){}

    SkinMesh(int vertexNum):m_vertexNum(vertexNum)
    {     
       m_vertexs = new Vertex[vertexNum];
    }

    ~SkinMesh()
    {
       if(m_vertexNum>0)
           delete[] m_vertexs;
    }  
 
    void UpdateVertices()
    {
       for(int i=0; i<m_vertexNum; ++i)
       {
           m_vertexs[i].BlendVertex();
       }
    } 

    void DrawStaticMesh(float x, float y, float z)
    {
       glColor3f(0,1.0,0);
       glPointSize(4);
       glBegin(GL_POINTS);

       for(int i=0; i<m_vertexNum; ++i)
           glVertex3f(m_vertexs[i].m_x+x,m_vertexs[i].m_y+y,m_vertexs[i].m_z+z);

       glEnd();      
       glBegin(GL_LINE_LOOP);

       for(int i=0; i<m_vertexNum; ++i)
           glVertex3f(m_vertexs[i].m_x+x,m_vertexs[i].m_y+y,m_vertexs[i].m_z+z);

       glEnd();
    }
 
    void Draw()
    {
       glColor3f(1.0,0, 0);
       glPointSize(4);
       glBegin(GL_POINTS);

       for(int i=0; i<m_vertexNum; ++i)
           glVertex3f(m_vertexs[i].m_wX,m_vertexs[i].m_wY,m_vertexs[i].m_wZ);

       glEnd();     
       glBegin(GL_LINE_LOOP);

       for(int i=0; i<m_vertexNum; ++i)
           glVertex3f(m_vertexs[i].m_wX,m_vertexs[i].m_wY,m_vertexs[i].m_wZ);

       glEnd();
    }

    int m_vertexNum;
    Vertex* m_vertexs; //array of vertices in mesh  
};
Bone* g_boneRoot;
Bone* g_bone1, *g_bone2, *g_bone31, *g_bone32;

void buildBones()
{
    g_boneRoot = new Bone(0, 0, 0);
   
    g_bone1 = new Bone(0.2, 0, 0); 
    g_bone2 = new Bone(0.2, 0, 0); 

    g_bone31 = new Bone(0.2, 0.1, 0);
    g_bone32 = new Bone(0.2, -0.1, 0);
 
    g_boneRoot->SetFirstChild(g_bone1);
    g_bone1->SetFirstChild(g_bone2);
    g_bone2->SetFirstChild(g_bone31);
    g_bone31->SetSibling(g_bone32);
}

void deleteBones()
{
    delete g_boneRoot;
    delete g_bone1;
    delete g_bone2;
    delete g_bone31;
    delete g_bone32;
}
 
void animateBones()
{
    static int dir=-1, dir2=-1;

    //animate bones manually
    g_bone1->m_y +=0.00001f*dir;   

    if(g_bone1->m_y<-0.2 || g_bone1->m_y>0.2)
       dir*=-1;

    g_bone32->m_x +=0.00001f*dir2;

    if(g_bone32->m_x<0 || g_bone32->m_x>0.2)
       dir2*=-1;
}
 
SkinMesh* g_mesh;

void buildMesh()
{
    float _meshData[]=
    {  //x,y,z
       -0.1,0.05,0,            
       0.1,0.05,0,             
       0.3,0.05,0,      
       0.45,0.06,0,
       0.6,0.15,0,
       0.65,0.1,0,   
       0.5,0,0,
       0.65,-0.1,0,
       0.6,-0.15,0,
       0.45,-0.06,0,
       0.3,-0.05,0,     
       0.1,-0.05,0,
       -0.1,-0.05,0,    
    };

    float _skinInfo[]=
    {  //bone_num,bone id(0,1,2,31 or 32), bone weight 1~4,
       1,  0, -1, -1, -1,    1.0, 0.0, 0.0, 0.0,
       2,  0,  1, -1, -1, 0.5, 0.5, 0.0, 0.0,
       2,  1,  2, -1, -1,  0.5, 0.5, 0.0, 0.0,
       2,  2,  31, -1, -1, 0.3, 0.7, 0.0, 0.0,
       2,  2,  31, -1, -1, 0.2, 0.8, 0.0, 0.0,
       1,  31, -1, -1, -1, 1.0, 0.0, 0.0, 0.0,
       2,  31, 32, -1, -1, 0.5, 0.5, 0.0, 0.0,
       1,  32, -1, -1, -1, 1.0, 0.0, 0.0, 0.0,
       2,  2,  32, -1, -1, 0.2, 0.8, 0.0, 0.0,
       2,  2,  32, -1, -1, 0.3, 0.7, 0.0, 0.0,
       2,  1,  2, -1, -1,  0.5, 0.5, 0.0, 0.0,
       2,  0,  1, -1, -1, 0.5, 0.5, 0.0, 0.0,
       1,  0, -1, -1, -1,    1.0, 0.0, 0.0, 0.0,
    };

    int vertexNum = sizeof(_meshData)/(sizeof(float)*3);

    g_mesh = new SkinMesh(vertexNum); 

    for(int i=0; i<vertexNum; ++i)
    {
       g_mesh->m_vertexs[i].m_x = _meshData[i*3];
       g_mesh->m_vertexs[i].m_y = _meshData[i*3+1];
       g_mesh->m_vertexs[i].m_z = _meshData[i*3+2];           
    }

    //set skin info
    for(int i=0; i<vertexNum; ++i)
    {
       g_mesh->m_vertexs[i].m_boneNum = _skinInfo[i*9];

       for(int j=0; j<g_mesh->m_vertexs[i].m_boneNum; ++j)
       {
           Bone* pBone = g_boneRoot;
           if(_skinInfo[i*9+1+j]==1)
              pBone = g_bone1;
           else if(_skinInfo[i*9+1+j]==2)
              pBone = g_bone2;
           else if(_skinInfo[i*9+1+j]==31)
              pBone = g_bone31;
           else if(_skinInfo[i*9+1+j]==32)
              pBone = g_bone32;
 
           g_mesh->m_vertexs[i].SetBoneAndWeight(j, pBone, _skinInfo[i*9+5+j]); 
       }
    }  

    //compute bone offset
    g_boneRoot->ComputeWorldPos(0, 0, 0);
    g_boneRoot->ComputeBoneOffset();
}

void deleteMesh()
{
    delete g_mesh;
}

void myInit()
{
    buildBones();
    buildMesh();
} 

void myQuit()
{
    deleteBones();
    deleteMesh();
}
 
void myReshape(int width, int height)
{
    GLfloat h = (GLfloat) height / (GLfloat) width;
    glViewport(0, 0, (GLint) width, (GLint) height);
    glMatrixMode(GL_PROJECTION);
    glLoadIdentity();

//  glFrustum(-1.0, 1.0, -h, h, 5.0, 60.0);
    glFrustum(-1.0, 1.0, -h, h, 1.0, 100.0);

    glMatrixMode(GL_MODELVIEW);
    glLoadIdentity();
    glTranslatef(0.0, 0.0, -1.0);
}

void myDisplay(void)
{
    glClear(GL_COLOR_BUFFER_BIT);
 
    //draw original mesh
    g_mesh->DrawStaticMesh(0,0,0);
      
    //move bones
    animateBones();

    //update all bone's pos in bone tree
    g_boneRoot->ComputeWorldPos(0, 0, 0);

    //update vertex pos by bones, using vertex blending
    g_mesh->UpdateVertices();  

    //draw deformed mesh
    g_mesh->Draw();

    //draw bone
    g_boneRoot->Draw();

    glFlush();
    glutSwapBuffers();  
}

void myIdle(void)
{
    myDisplay();
}

int main(int argc, char *argv[])
{
    glutInit(&argc, argv);

    glutInitDisplayMode(GLUT_RGB | GLUT_DEPTH | GLUT_DOUBLE);

    glutInitWindowPosition(100, 100);

    glutInitWindowSize(640, 480);

    glutCreateWindow("A simplest skinned mesh DEMO, by happyfirecn@yahoo.com.cn");

    glutDisplayFunc(myDisplay);

    glutReshapeFunc(myReshape);

    glutIdleFunc(myIdle);

    myInit();

    glutMainLoop();

    myQuit();

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

推荐阅读更多精彩内容