unity3D研究之Skinned Mesh原理解析

Unity Mesh、MeshFilter、MeshRenderer扫盲 Unity Mesh、MeshFilter、MeshRenderer 阅读详情

讲述骨骼动画的资料很多,但大部分都是针对 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 不过是一层皮,一件衣服。

如何理解骨骼?请看第二个观念:骨骼可理解为一个坐标空间。

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

bonejoint.png

骨骼只是一个形象的说法,实际上骨骼可理解为一个坐标空间,关节可理解为骨骼坐标空间的原点。关节的位置由它在父骨骼坐标空间中的位置描述。上图中有三块骨骼,分别是上臂,前臂和两个手指。 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)------>Bone space

------>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 顶点的运动,例子中甚至没有使用矩阵。本例子仅作理解骨骼动画之用。

skmeshdemo.png

截图中绿色网格是模型原始形状,蓝色是骨骼,红色是动画时的模型形状。 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.youkuaiyun.com/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 m_y >0.2)
       dir *=-1;

    g_bone32 ->m_x +=0.00001f*dir2 ;

    if (g_bone32 ->m_x 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 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 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;
}

作者:n5

Email: happyfirecn##yahoo.com.cn

Blog: http://blog.youkuaiyun.com/n5

2008-10 月

Last Modify:2011-1-1

From:http://blog.youkuaiyun.com/n5/article/details/3105872

Unreal Cookbook:神奇的Spline Mesh 使用Spline Mesh快速建立一个管道。:) 阅读详情

相关推荐

Unity资源逆向解析实战:AssetStudio核心原理与资源提取全流程

在游戏开发与资源管理中,资源序列化与反序列化是核心技术环节。Unity引擎将纹理、模型、音频等资产打包成高效的二进制格式(如AssetBundle、.assets文件),以实现资源管理和运行时加载。理解其序列化原理,对于资源复用、性能分析和故障排查具有重要价值。AssetStudio作为一款开源工具,通过解析Unity的类型树(TypeTree)和对象表,在内存中重建资产对象,并支持将资源导出为PNG、FBX、WAV等通用格式。这一过程解决了从打包资源中无损提取原始素材的工程难题,广泛应用于技术研究、资源回

dbp5156的博客 401

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

作者:n5 Email: happyfirecn@yahoo.com.cn Blog: http://blog.youkuaiyun.com/n5 2008-10 月   Histroy: Version:1.02 Date:2010-1-1        修改了一些错误(用删除线标出 ),补充了一些材料,        ps:本文以后只维护优快云 blog上的版本,请转载者保

咸鱼的明天 1332

Unity资源逆向解析实战:AssetStudio核心原理与资源提取指南

在游戏开发与资源管理中,资源序列化与反序列化是核心技术环节。Unity引擎通过其特有的序列化系统,将游戏对象、纹理、模型等资产转换为紧凑的二进制格式(如.assets、AssetBundle),以实现高效存储与加载。理解其底层数据结构,尤其是TypeTree(类型树)这一核心“数据蓝图”,是进行资源解析与提取的关键。这项技术对于资源回收、故障诊断、性能分析与合规学习具有重要价值,广泛应用于资源恢复、逆向工程研究、Mod制作支持等场景。AssetStudio作为社区广泛使用的开源工具,正是基于对Unity序列

weixin_30247307的博客 373

Unity3D 实用技巧 - 理解骨骼动画原理 & 在Unity3D中使用

其实我们在游戏中,会常会应用到动画。所以今天小编与大家一起来深入了解“动画”,其实动画主要分为关节动画、骨骼动画、单一网格模型动画(关键帧动画)。1)关节动画:把角色分成若干独立部分,一个部分对应一个网格模型,部分的动画连接成一个整体的动画,角色比较灵活,Quake2中使用这种动画;2)骨骼动画:广泛应用的动画方式,集成了以上两个方式的优点,骨骼按角色特点组成一定的层次结构,有关节相连,可做相对运...

unityofficial的博客 2935

5分钟搭建MediaPipe+Unity手势交互:零设备实时驱动虚拟手

计算机视觉中的手势识别技术,通过分析图像或视频流中手部关键点的位置与运动,实现对用户意图的非接触式感知。其核心原理通常基于深度学习模型,从摄像头输入中实时检测并追踪21个手部关节点,形成骨架数据。这项技术的价值在于为自然用户交互提供了基础,无需穿戴设备即可实现直观操控,极大地降低了交互门槛。在应用场景上,它广泛服务于虚拟现实、增强现实、智能家居控制以及游戏交互等领域。本文聚焦于利用MediaPipe这一高效开源框架进行实时手势识别,并结合Python与OpenCV构建处理服务,最终通过本地网络通信将数据流驱

weixin_33836874的博客 385

Unity口型动画实战:基于LipSync实现角色语音同步与面部绑定

在实时3D内容开发中,角色动画是提升沉浸感的关键技术之一,其中口型同步(Lip Sync)通过分析音频中的音素信息,驱动面部混合形状(Blend Shape)实现与语音匹配的动画效果。其核心原理是将语音识别结果映射到预设的嘴型变形目标,从而自动化生成自然的口型动画,显著降低传统手动K帧的制作成本。这项技术在游戏开发、虚拟现实和动画制作中具有重要价值,尤其适用于需要大量角色对话的交互式应用场景。本文以Unity3D为平台,深入解析如何利用LipSync插件,从模型绑定、音素映射到动画集成的完整工作流,帮助开发

weixin_30411239的博客 290

AssetStudio实战指南:从零掌握Unity资源提取与逆向分析

在游戏开发与数字内容创作领域,资源管理与逆向工程是核心技术环节。其原理在于解析和提取引擎打包后的资产文件,理解资源序列化与依赖关系。掌握这项技术对于学习成熟项目架构、进行资源故障排查以及合规的二次创作具有重要价值,广泛应用于游戏分析、素材提取和开发调试等场景。AssetStudio作为该领域的核心工具,能够高效处理Unity的.assets和.bundle文件,支持模型、贴图、音频等资源的预览与导出。通过本指南,您将系统学习从环境配置、资源定位到高级解析的完整工作流,并了解如何应对常见的加密格式与导出问题,

weixin_30596165的博客 330

SplineMesh:一个Unity插件,可使用贝塞尔曲线实时创建弯曲内容

什么是SplineMesh? 受Unreal Engine 4样条线组件的启发,SplineMesh是一个Unity 3D插件,可用于创建弯曲内容: 通用样条线组件 网格沿样条线弯曲 沿样条线的2D形状拉伸 易于使用的编辑器 完整的数学贝塞尔曲线 强大的实时性能 如果您喜欢SplineMesh,请花一些时间对的进行投票,这会很有帮助! 评论也很感激。 如何使用它? 在从资产商店中获取资产。 它包括文档,评论和完整的展示,并附有示例供您参考。 请通过商店或github问题页面与作者联系。 如何贡献? SplineMesh是免费的开放源代码。 您可以通过购买来支持作者, 不包含任何其他功能。 您还可以通过发送代码,拉取请求或您自己的示例组件为代码本身做出贡献,作者将很高兴将您的工作包括在商店中。

性能优化——骨骼动画原理

蒙皮:把模型顶点和骨骼做一个对应关系,这就是蒙皮权重。蒙皮要做的事情,是指定某个顶点受到了多少根骨骼的影响,在运动的时候,顶点根据权重的百分比来跟随骨骼运动。 举个栗子:比如一个顶点收到两个跟骨骼的影响,第一根骨骼的权重是30%,第二根骨骼的权重是70%。在想跟骨骼同时移动的时候,第一根骨骼想左移动10米,第二根骨骼向右移动10米,假若右使是正方向,那么这个顶点实际移动的位置应该是-10*0.3+10*0.7=4,也就是最终向右移动了4米,实际计算中不会简单乘百分比,而是会用矩阵来计算 骨骼的父子关系:

qq_43761888的博客 964

BQ25798双电源输入4节电池升降压充电与最大功率跟踪于光伏板应用

介绍一颗具有窄范围直流电压NVDC、路径管理PPMT、光伏板最大功率跟踪及反向放电OTG升降压充电控制器,带I2C接口能对内部寄存器做控制

linkedin_38804946的博客 1678

【Android14 ShellTransitions】Transition就绪:双Transaction与SurfaceControl显示时机剖析

本文深入剖析了Android14 ShellTransitions框架中Transition就绪阶段的核心机制。重点解释了为何需要“双Transaction”来精确控制SurfaceControl的显示与隐藏时机,以确保Activity切换动画的流畅性。通过分析onTransactionReady流程、窗口绘制状态机及动画目标提升策略,揭示了系统如何协调WMCore与WMShell,实现从状态收集到动画播放的无缝衔接。

ooo22的博客 529

unity怎么显示骨骼_浅谈Unity3D 骨骼动画

最近研究了一下游戏中模型的骨骼动画的原理,做一个学习笔记,便于大家共同学习探讨。ps:最近改bug改的要死要活,博客写的吭哧吭哧的~首先列出学习参考的前人的文章,本文较多的参考了其中的表述:一、骨骼动画、关节动画、关键帧动画在实际的游戏中,用的最多的是这三种基本的动画。在关键帧动画中,模型在每个关键帧中都是一个固定的姿势,相当于一个“快照”,通过在不同的关键帧中进行插值平滑计算,可以得到一个较为流...

weixin_39667652的博客 2544

保姆级教学:从0搭建微信小程序SpringBoot+Vue(一)

目标:实现一个微信小程序从后端数据库MySQL获取的商品展示页。 框架:后端采用SpringBoot,前端采用Vue,数据库MySQL。 部署工具:IntelliJ IDEA 2024.2专业版+HbuilderX+Node+微信开发者工具,jdk1.8已安装

weixin_44545610的博客 4925

不会美术也能用Unity制作简单的2D骨骼动画(二)添加骨骼

紧接上一步的做法,如图所示添加在Rock Golem下添加一根骨骼,并命名为Hips,作为臀部骨骼 图中黄色的针就是骨骼,点击黄色方框可对移动骨骼,点击针部则可旋转骨骼 按下图中的层级顺序分别创建Spine,Neck,Skull,Jaw骨骼,并调整至如图所示 接着进行骨骼绑定 选择要绑定的对象精灵,然后选择窗口左上角的Window-&amp;gt;Animation2D-&amp;gt;SpriteMesh...

YYYJ_BOY的博客 1万+

【CTFSHOW】web入门 黑盒测试

文章目录写在前面web380web381web382web383web384web385web386web387web388web389web390web391web392web393web394web395参考资料 写在前面 一直没搞过渗透,先学学黑盒测试吧 web380 进入题目发现是一个关于灯泡的悲伤故事,点进去,看到跳转到page_1.php页面。 我们访问page.php试试: 发现报错:$id.php,可以猜想需要传名称为id的参数,来访问id.php。我们访问page.php?id=1试试

7ruth0hn的博客 1897

iOS马甲包过审实战:如何用5天时间搞定4.3审核(附完整代码混淆方案)

本文提供了一套为期五天的高效实战方案,专门解决iOS应用因重复或抄袭问题而被App Store的4.3条款拒绝的难题。方案涵盖深度分析、代码混淆、资源翻新及环境隔离等系统性步骤,并附有完整的代码混淆示例,旨在帮助开发者在合规框架内重塑应用,显著提升过审成功率。

weixin_29234239的博客 147

Unity 3D SplineMesh样条线工具扩展,链条制作,履带转动,绳子制作,调整弯曲度及一些好用的示例

SplineMesh样条线工具扩展 Spline Mesh工具在Unity商店是免费的 Unity商店资源链接 下面是我自己做的一些示例,用起来也很方便 一、锁链 制作的第一个示例是一个链条 解释第一个参数与第二个参数用法: 循环物体: 每隔一个变化的物体,比如链条只需要一个环就可以了如果链条有多种材质或者中间有破损的链条可以加进去 循环旋转: 每隔一个物体需要怎样旋转,链条就需要两个,一个需要转90°,一个不需要 剩余的参数都好理解,自己用一下就知道了 二、统一控制链条 这个没什么好说的,就

CTangZe的博客 6835

Intellij IDEA 使用教程(一)Jdk安装和配置

一.JDK下载 这里下载的是Java SE 8u181 64位版本。可以根据自己电脑配置来选择不同版本。 官网下载JDK 二.安装JDK 双击运行jdk-8u181-windows-x64.exe安装程序 选择安装目录,然后点击下一步 等待安装 安装JRE,就是java的运行环境,可以安装在java目录下 等待安装 安装完成。点击关闭。 三.java环境变量配置 关于环境变量的配置,...

岁月不饶人,我亦未曾饶过岁月 4055

推荐开源项目:Unity 3D插件——SplineMesh

![SplineMesh Logo](https://s9.postimg.cc/5rf3y5lxr/bandeau_github.png) 在游戏开发和虚拟现实的领域里,打造流畅而曲线优雅的内容至关重要。今天,我们要向你推荐一个能够实现这一目标的强大工具——[SplineMesh](https://assetstore.unity.com/packages/tools/modeling/spl

gitblog_00076的博客 1064

用 Docker 一键部署 SillyTavern酒馆 mac/windows/linux通用教程,我再也不手动折腾了!

本文介绍了如何使用 Docker 一键部署 SillyTavern 项目,适用于 Mac、Windows 和 Linux 系统。SillyTavern 是一个本地部署的大语言模型聊天界面,支持多种插件和扩展。文章详细说明了安装 Docker、拉取项目、启动容器、配置白名单、启用插件等步骤,并提供了常见问题的解决方法。通过 Docker 部署,用户可以避免繁琐的手动配置,快速搭建和运行 SillyTavern,适合希望简化部署流程的用户。

代码简单说 Vue、JAVA、PHP、Node.js 熟练运用,接口、架构、性能全搞定。 2623

win10 跳过U8环境检测的IE WebControls.rar

win10 安装u8的时候 提示IE WebControls 不通过,这个工具可以欺骗u8的检查。 跳过U8环境检测的IE WebControls。提示导入失败后,重新检测通过。

Unity动态绘制曲线Mesh的代码

之所以有这一篇文章,是因为项目需求,要在平面地图上用曲线画出路径,本来美术都把曲线画完了,可是地图的改动让一切作废,最后我决定做个动态的可调整曲线工具,花了好几天时间也算是圆满完成,重点参考了插件Math Library For Unity里的曲线工具,在此表示感谢! 这是游戏中效果: 编辑器窗口里的样子: Inspector里的样子: CurveMeshBuild

lzdidiv的专栏 1万+

廖雪峰Python教程[完整版]

廖雪峰的python教程整理的,已经排好版了,而且还是完整的,适合小白

Unity中使用GPU Instancing优化SkinnedMesh渲染

有这么个需求: 一堆不会动的SkinnedMesh, 通过物理模拟出死亡姿势后, 需要优化性能, 不然才10FPS首先想到的做法是转成StaticMesh. 试了一下直接超出了Unity的顶点Index数量限制, 所以放弃, 再说转换完后也不方便编辑. 另一个思路就是使用Instanced Skinned Mesh Rendering, 找到两个参考:https://github.com/Unit...

逍遥剑客 1万+

精雕软件(JDpaint) 5.5 绿色版

精雕软件(JDpaint) 5.5 绿色版精雕软件(JDpaint) 5.5 绿色版精雕软件(JDpaint) 5.5 绿色版

SIMATIC S7-1200, S7-1500 PID 控制[手册]

SIMATIC S7-1200, S7-1500 PID 控制[手册]

Unity记录一下skinnedmeshrender换装 系统的关键

其实,关键就在于SkinnedMeshRenderer 有点废话 其实,关键在于Mesh和bones要匹配,也就是mesh从哪里来的,那个节点下bones都要包含进来 下面这段代码就是不包含节点也没关系,指向引用也是可以的 但是bones的引用不能不设置,或者Null smrBody.sharedMesh.CombineMeshes(combineInstances.ToArray(), true, false); //换装实现 smrBody.bo

大话程序员 963

Unity 蒙皮动画 SkinnedMesh 使用GPU Instance

我想很多开发游戏的小伙伴都希望自己的场景内能渲染越多物体越好,甚至是能同时渲染成千上万个有自己动作的游戏角色就更好了。但不幸的是,渲染和管理大量的游戏对象是以牺牲CPU和GPU性能为代价的,因为有太多Draw Call的问题,如果游戏对象有动画的话还会涉及到cpu的蒙皮开销,最后我们必须找到其他的解决方案。那么本文就来聊聊利用GPU实现角色的动画效果,减少CPU端的蒙皮开销;同时将渲染10,000个带动画的模型的Draw Call从10,000+减少到22个。

Sevol_Y 2537

unity3d 动画重定向 动力学骨骼插件DynamicBone

原创 模型导入装配什么的就不说了。 1.要做动作的模型,Rig----从这个模型创建Avatar   Animations---取消导入动画。 2.有动画的模型,Rig----从其他模型创建Avatar,选中新模型的Avatar。 3.动画文件选择 Rig----从这个模型创建Avatar。全部选中humanoid。 动力学骨骼插件DynamicBone:

我是重阳 的专栏 8793
上一篇: unity3d研究之版本管理(SVN)
下一篇: unity3d研究之c# 单例模式
GameMonkey
博客等级 码龄19年 60粉丝 17原创
评论 4
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

个

红包个数最小为10个

元

红包金额最低5元

当前余额3.43元 前往充值 >
需支付:10.00元
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付元
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值