C++面向对象基础

这篇文章是我去年3月份发在哈工大微软俱乐部邮件列表中的科普帖。


这个帖子将会和大家一起讨论一下面向对象编程的一些基础知识,旨在为大家深入学习提供基础。大二同学正好在做软设一,需要对面向对象与泛型有一些了解,大一同学也学过了python,时机正宜。欢迎大家积极回复讨论。

一、关于面向对象编程


一般来讲,对于面向过程的编程手法,一个程序是:

程序 = 数据结构 + 算法

整个过程就是解决相关问题的过程,使用合适的数据结构存储信息,合适的算法解决问题。 比如,对于一个采用面向过程的方式编写的学打酱油系统v0.1,大致如下:

struct 酱油瓶 {  
    string 酱油牌子
    integer 酱油量
}

void 打酱油(){  
    打开车门()
    上车()
    开车到粮油店()
    打开车门()
    下车()
    酱油瓶1.酱油牌子 = 康师傅
    酱油瓶1.酱油量 = 100
    //……
}

我们使用酱油瓶数据结构保存数据,打酱油函数实现打酱油的算法,最后成功地解决了打酱油这一历史性问题!

然而,这种面向过程式的编程方式随着软件架构越来越庞大,变得更加难以满足需求。进而,为了使软件结构易于设计、易于维护、方便扩展,越来越多的人选择了面向对象的方式来设计和实现比较庞大的系统。对于面向对象编程思想来讲,一个程序是:

程序 = 对象 + 对象 + 对象 + ……

所以面向对象编程的前提是要有妹子,然后你就可以面对着他编程了!

在详细讨论之前,首先要区分两个概念:类和对象。

类是一种类型、类别,它是一种抽象。而对象是这种抽象的实例。比如说我是人,他是人,她也是人。这里“人”就是一个类,我、她、他就是“人”这个类的实例。例如下面的C++<代码定义了人类:

class Human{  
    //xxxxxxxxx
};

而如果要定义Human这个类的对象,则是:

Human lishengqiu;

二、面向对象编程语言的特征


支持面向对象的编程语言普遍拥有以下三(四)个特征:

  1. 封装
  2. 继承
  3. 多态
  4. (抽象)

对于不同的编程语言,其实现机制可能不同,但思想应基本相似。下面我们就分别谈谈各个特征的具体内容。

1. 封装

封装其实是比较容易理解的。比如:CPU把内部复杂的实现全部封装在一块小芯片上,只留出诸多引脚提供给外部硬件进行访问;微波炉、电冰箱都把内部复杂的控制电路封装起来,只留下几个按钮提供傻瓜式的服务……

从上面两个例子我们总结出封装的特点:

  1. 对象的数据和操作都是这个对象的提供的。 – 也就是说,编程语言要提供数据和操作捆绑的能力。
  2. 提供操作接口给用户使用,而不是用户直接控制内部数据。 – 我们并不控制微波炉内部的任何结构与属性,而是通过操作面板对属性(比如每次加热多长时间,光波还是微波加热)进行控制;查看这些属性,也只能通过面板来查看。也就是说,编程语言要提供对成员的访问权限的控制。一般不会将对象的数据直接暴露给用户,而是让使用者通过我们提供的共有操作来对对象的属性进行改变。
  3. 封装的目的在于隐藏实现细节,提高安全性。对于用户的每一次操作,我们都可以有足够的能力自动对输入进行检查,以防止不合法的数据或操作。 在C++、Java、C#等这一大家子语言中,使用public/private关键字来对访问权限进行控制。以C++为例:
class Car{  
 public:
    int GetCarNo(){return carNo;}
    void SetCarNo(int number){if(carNo 

上面这段代码首先定义了一个叫做Car的类, 它拥有一个属性叫做carNo(车牌号?车型号?随便啦╮(╯▽╰)╭)。这个属性是私有的(private),不允许外部代码对其进行访问。同时提供了GetCarNo和SetCarNo公有成员函数来提供对carNo的修改。

在SetCarNo中,我们还对号码的合法性进行了判断。如果小于零,我们就认为输入有误,抛出错误(throw);无误的话就就将number赋值给carNo。

再考虑以下代码:

class Time{  
 public:
    int GetTimeBySec(){return hour*3600+minute*60+second;}
    double GetTimeByMinute(){return hour*60 + minute + second / 60.0;}
private:  
int hour, minute, second;  
};

当我们使用这个Time类时,我们不需要知道它内部数据的存储方式(是以小时、分钟、秒数分开存储,还是以只秒的总数存储,还是以分钟?毫秒?微秒?……);同时,Time类还提供了两种获得时间的方法:以秒为单位,以分钟为单位。调用者只需要按需调用接口函数就可以了,而不必关心内部的实现方式。

封装隐藏了内部的实现细节,这些是类的编写者所需要考虑的问题,而不是使用者。使用者不应该拥有涉及类的内部实现的能力,因为这将提高耦合,降低软件的可维护性。

不过,依然有人对封装的必要性持相反意见,有兴趣的同学可以搜索一看。

2. 继承

很多时候我们需要继承来表示对象之间的关系,有点类似父母与孩子。 在继承中,被继承的类(父亲)叫做父类、基类,而孩子类就叫做子类、派生类。C++中使用如下方式进行类之间的继承:

class Base{  
public:  
    int BaseMethod(){return 1;}
};
class Sub : public Base{  
public:  
    int SubMethod(){return 0;}
};

上述代码定义了一个基类Base,和一个方法BaseMethod;子类Sub采用公有继承方式继承Base,以及自己的SubMethod。这时对于子类来说,他将同时拥有BaseMethod和SubMethod两个方法,这就是继承最基本的作用。比如:

Sub sub;  
sub.BaseMethod();  //将返回1  
sub.SubMethod();    //将返回2  

继承提高了代码的复用程度,我们可以将一些公共的操作或属性抽象到父类中,以减少代码量。比如:

class Actor{  
protected:  
    string name;
    unsigned int hp;
    unsigend int mp;
};
class Luna : public Actor{  
};
class Kael : public Actor{  
};

上述代码中的protected关键字表示其数据与操作对外部不可访问,而对子类可以访问。

但是,继承也可能造成可读性的降低。如果继承的深度过高,很多属性和方法将很难确定究竟是由那一层子类提供的。

3. 多态
1. 没有多态的继承

考虑以下代码:

class Actor{  
public:  
    void attack(){}
protected:  
    string name;
    unsigned int hp;
    unsigend int mp;
};

class Luna : public Actor{  
public:  
    void attack(){
        cout << "Luna attack!" <<endl;
    }
};

class Kael : public Actor{  
public:  
    void attack(){
        cout << "Kael attack!" <<endl;
    }
};

基类Actor和子类Luna、Kael的attack函数实现并不相同。

同时,由于Luna和Kael是两个不同的类型,此时如果我们有一个函数希望调用luna和kael的attack方法,不得不这么做:

void letKaelAttack(Kael* kael){  
    //do something...
    kael->attack();
    //do something...
}

void letLunaAttack(Luna* luna){  
    //do something...
    luna->attack();
    //do something...
}


Luna luna;  
Kael kael;  
letLunaAttack(&luna);  
letKaelAttack(&kael);  

也就是说,我们无法写出一个通用的函数来调用不同子类的attack。如果我们有上百个英雄,就必须要写上百个letXXXAttack函数——即使他们都是Actor,有着相同的方法定义,而且也许函数中除了XXX->attack()之外所有语句都一样!

我们期望的解决方式也许是这样的,使用一个统一的函数来实现这种统一的功能。一个自然而然的想法是使用父类的指针作为形参。然而,直接使用actor->attack()将调用Actor类的attack函数(就是什么也不做的版本),因此我们需要对父类的指针做出一些转换。如果能够判断一个父类指针所指向的是哪一个子类对象就好了,这样我们就可以写出如下代码:

void letActorAttack(Actor* actor){  
    //do something...
    if(actor is Luna)
        ((Luna*)actor)->attack();
    else if(actor is Kael)
        ((Kael*)actor)->attack();
    //do something...
}

在C++中,actor is Luna这样的功能使用dynamic_cast关键字实现。即:

if(dynamic_cast<luna>(actor))

如果actor不是Luna, dynamic_castLuna*>(actor)将返回NULL。

然而,这样的解决方案仍然不完美。且不说依然要为每一个英雄写一个判断语句,如果我们需要新添加一个英雄的话,也需要修改letActorAttack函数。

2. 多态

继承使得子类可以表现的像父类一样,而多态则让父类表现的像子类。在C++中要使用多态,需要使用virtual关键字,将父类中的函数定义为虚函数。一旦声明虚函数,这个函数将一直是虚函数,不论子类中是否再有virtual关键字。

class Actor{  
public:  
    virtual void attack(){}
protected:  
    string name;
    unsigned int hp;
    unsigend int mp;
};

class Luna : public Actor{  
public:  
    void attack(){
        cout << "Luna attack!" <<endl;
    }
};

class Kael : public Actor{  
public:  
    void attack(){
        cout << "Kael attack!" <<endl;
    }
};

此时,我们的letActorAttack函数将非常简单:

void letActorAttack(Actor* actor){  
    //do something...
    actor->attack();
    //do something...
}

Luna luna;  
Kael kael;  
letActorAttack(&luna);  
letActorAttack(&kael);  

如果我们有一位新的英雄,只要它继承了Actor,实现了attack(),就可以把它的地址传给letActorAttack,而letActorAttack不需要任何改动。

另外需要特别说明的是,父类的析构函数应声明为虚函数,否则当使用父类指针delete子类时,子类的析构函数将不会被调用。这并不特殊,因为所有没有声明为虚函数的函数都如此;只是这个函数的地位有些特殊罢了。

4. 抽象
抽象类

还用我们上一个例子。对于Actor这个类来讲,它其实并没有实际意义,因此下面这句话其实是没有任何意义的:

Actor actor

而且,actor中的attack函数并没有实际意义,如果一个英雄继承了Actor但是却没有实现自己的attack,依旧不是我们想要的。

为了解决上述问题,C++采用纯虚函数的方式来解决。而当一个类中包含了纯虚函数,他将成为一个抽象类,并无法实例化。

我们将代码修改如下:

class Actor{  
public:  
    virtual void attack() = 0; //Note Here!
protected:  
    string name;
    unsigned int hp;
    unsigend int mp;
};

class Luna : public Actor{  
public:  
    void attack(){
        cout << "Luna attack!" <<endl;
    }
};

class Kael : public Actor{  
public:  
    void attack(){
        cout << "Kael attack!" <<endl;
    }
};

virtual void attack() = 0;这段代码声明了一纯虚函数,他将Actor这个类变成了一个抽象类。此时的Actor将无法实例化,仅可以作为指针使用。如果Sniper继承自Actor,但是没有实现attack函数,那么他将也无法实例化,即无法创建出Sniper实例。

抽象类做接口

我们现在希望做一个音乐播放器。播放部分比较重要的就是解码器了,但是每个不同的音频解码库的使用方法都不一样。目前已经准备好APE、MP3和OGG的解码器了(MACLib, mpg123, OggVorbis),如何才能有效地使用这些解码器呢?

利用我们目前已经了解的继承、多态、抽象类,可以很容易想到解决方案:

  • 我们可以为每一个解码器做一层封装,将每一个解码器的使用方式封装起来。这样上层使用者不需要知道我们内部的具体实现,只需要调用我们的公开函数即可。
  • 我们对每一个封装好的类进行抽象,提取出公共的接口,使用一个抽象类作为基类,不同的解码器封装继承自抽象基类。
  • 得益于多态,我们使用抽象基类指针即可操作不同的子类对象。

下面的代码取自我曾经实际做过的项目,适当做了省略和修改:

class Decoder{  
public:  
    virtual int CheckSupport(const char*) = 0;
    virtual int Open(const char*) = 0;
    virtual int Seek(double) = 0;
    virtual void GetFileExt(char* ,char*) = 0;
    virtual int GetFormat(long*,int*) = 0;
    virtual int GetFileInfo(Info*) = 0;
    virtual int GetFileInfo(const char*, Info*) = 0;
    virtual int ReadAndDecode(unsigned char*,int,unsigned int*) = 0;
    virtual double GetPosition(int buffered = 0) = 0;
    virtual double GetLength() = 0;
    virtual int Close() = 0;
    virtual ~Decoder(){}
};

class ApeDecoder : public Decoder{  
private:  
    //省略数据成员
public:  
    ApeDecoder();
    ~ApeDecoder();
    int CheckSupport(const char* fname);
    int Open(const char* fname);
    int Seek(double sec);
    void GetFileExt(char* Description, char* FileExt);
    int GetFormat(long* rate,int* channel);
    int GetFileInfo(Info* info);
    int GetFileInfo(const char* filename, Info* info);
    int ReadAndDecode(unsigned char* buf,int size,unsigned int* read);
    double GetPosition(int buffer_size = 0);
    double GetLength();
    int Close();
};

class MP3Decoder : public Decoder{//...}  
class OggDecoder : public Decoder{//...}  

Decoder是一个抽象类,他甚至什么数据都没有,只有成员函数。我们使用这个类作为接口,来操作子类:

Decoder* decoder = new MP3Decoder();

不过很多时候,我们并不像让用户知道子类的名字,因为这意味着需要提供子类的声明。例如,我们想把我们写的这些代码封装成库,提供给其他用户调用,尽量隐藏细节。这时我们通常会采用一些合适的设计模式,例如简单工厂模式,将子类的制造过程也抽象出来:

Decoder* CreateDecoder(string name){  
    if(name == "mp3")
        return new MP3Decoder();
    else if(name == "ape")
        return new APEDecoder();
    else if(name == "ogg")
        return new OggeDcoder();
    else 
        return NULL;
}

此时,用户只需要Decoder的声明和CreateDecoder的声明就可以了,以及我们提供的库文件。调用时也非常简单:

Decoder* decoder = CreateDecoder("mp3");

所有的操作都被隐藏在抽象之下。而Decoder类的作用,仅仅是提供接口;由于具有多态性,decoder变量将表现的像他的孩子一样,而用户完全不需要知道任何细节,例如我们的MP3子类究竟是叫MP3Decoder还是叫Decoder_MP3?或是我们的MP3Decoder究竟使用的是哪一种mp3解码库(是mpg123还是ffmpeg)?同时,只要接口不做改变,我们所有的类的实现方式都可以随意更改,而用户不需要修改他们的任何代码就可以直接使用我们修改过的库。

在一些其他语言中,使用独立的关键字(例如interface)来提供对接口的定义。

三、尾声

这篇帖子简单介绍了面向对象编程语言的几大特征,很多支持面向对象方式编程的编程语言基本上会都在不同程度上支持这些特性。有一些语言会有所缺失,我们也可以认为他们是面向对象编程语言;而有一些语言则支持更加广义的特性。对于这些特性多多地加以合理利用,往往会使代码更加易于组织和构造,也更加易于编写和维护。

编写3dsmax骨骼动画导出插件

上一篇文章中我们讨论了蒙皮骨骼动画的基本原理,本文我们将继续编写3dsmax的骨骼动画导出插件。目前网上我找到的使用IGAME导出骨骼动画的文章只有那么几篇,而且讲得并不详细,自己还是踩了很多坑。之前也并没有相关经验,只能自己摸索着来并在此总结,希望能够让读者们绕坑而行。

1. 安装3dsmax sdk

在3dsmax的安装程序中会有一项“安装工具和实用程序”一项,选择其安装即可。

1

安装好后将maxsdk/howto/3dsmaxPluginWizard目录中的3dsmaxPluginWizard.ico、3dsmaxPluginWizard.vcz、3dsmaxPluginWizard.vsdir拷贝至vs目录中的vc/vcprojects中即可。我是用的是express 2013,目录名称叫做vcprojects_WDExpress。

2.配置Visual Studio

在vs的新建工程中将可以看到3dsmax导出插件的工程项目,新建后将自动生成程序入口代码。不过在开始之前还需要再做一些小动作:由于每次max加载插件后,一直到关闭3dsmax才会将句柄释放。也就是说当我们想测试插件时,每次都必须重启3dsmax才能重新加载插件,非常不方便。为了解决这个问题,我们可以再新建一个普通的DLL项目,所有的主体代码均在此DLL中实现。每次调用完毕后,立刻释放句柄。[1]

1

3.骨骼动画导出插件的编写

之后就可以开始导出插件的编写了。3dsmax sdk提供了一个叫做IGame的工具,具备很多实用的功能,可以方便我们收集场景信息。首先需要引入IGame的头文件,以及igame.lib。

导出模型、材质等过程这里就省略了,网上的教程应该有很多,可以搜索一下。

3.1 IGame初始化

我们首先需要做一点初始化的工作:获得IGame的相关接口,设置坐标系等等。坐标系设置好后,3dsmax会自动为我们计算矩阵变换。

bool exportSelected = (options & SCENEEXPORTSELECTED) ? true : false; IGameScene* scene = GetIGameInterface(); IGameConversionManager * cm = GetConversionManager(); UserCoord rightHandCoord = { 1, 1, 2, 5, 1, 0 }; cm->SetUserCoordSystem(rightHandCoord); scene->InitialiseIGame(exportSelected);

第一行首先确定用户是否是选择了“导出选择的物体”。IGame会根据此选项来创建场景树。

第四行设置了导出的坐标系,sdk的头文件中有详细的注释解释了UserCoord结构体各项的作用:

struct UserCoord{ //! Handedness /! 0 specifies Left Handed, 1 specifies Right Handed. */ int rotation; //! The X axis /! It can be one of the following values 0 = left, 1 = right, 2 = Up, 3 = Down, 4 = in, 5 = out. / int xAxis; //! The Y axis /! It can be one of the following values 0 = left, 1 = right, 2 = Up, 3 = Down, 4 = in, 5 = out. / int yAxis; //! The Z axis /! It can be one of the following values 0 = left, 1 = right, 2 = Up, 3 = Down, 4 = in, 5 = out. / int zAxis; //! The U Texture axis /! It can be one of the following values 0 = left, 1 = right / int uAxis; //! The V Texture axis /! It can be one of the following values 0 = Up, 1 = down */ int vAxis; };

下面首先先收集一下各个结点的信息。我们通过遍历整棵场景森林的方式,收集模型结点和骨骼结点的信息。导出模型、材质等内容本文就省略了,网上的教程应该有很多,搜索一下就可以了。我们来关注骨骼结点的统计:

map bones; void GetMeshNode(IGameNode* node, vector& nodes, int& totalVertexNum) { switch (node->GetIGameObject()->GetIGameType()) { case IGameObject::IGAMEMESH: /*此处省略*/ break; case IGameObject::IGAMEBONE: bones.insert(make_pair(node->GetNodeID(), node)); break; default: break; } int childNum = node->GetChildCount(); for (int i = 0; i < childNum; i++) { GetMeshNode(node->GetNodeChild(i), nodes, totalVertexNum); } }

 对所有根节点都调用此函数就可以收集到所有的骨骼结点信息。bones中存储的就是我们所有的骨骼结点,之后再导出骨骼和骨骼动画就非常简单了:

int startTime = staticcast(GetCOREInterface()->GetAnimRange().Start() / GetTicksPerFrame()); int endTime = staticcast(GetCOREInterface()->GetAnimRange().End() / GetTicksPerFrame()); boneHeader.FrameNum = (endTime – startTime) + 1; fwrite(&boneHeader, sizeof(boneHeader), 1, filp); for (int i = 0; i < boneHeader.FrameNum; i++) { for (map::iterator it = ::bones.begin(); it != ::bones.end(); it++) { float t = i * GetTicksPerFrame(); GMatrix gm = it->second->GetWorldTM(t); t /= 4.8; fwrite(&t, sizeof(t), 1, filp); Point4 p1 = gm.GetColumn(0); Point4 p2 = gm.GetColumn(1); Point4 p3 = gm.GetColumn(2); Point4 p4 = gm.GetColumn(3); fwrite(&p1.x, sizeof(p1.x), 1, filp); fwrite(&p2.x, sizeof(p1.x), 1, filp); fwrite(&p3.x, sizeof(p1.x), 1, filp); fwrite(&p4.x, sizeof(p1.x), 1, filp); fwrite(&p1.y, sizeof(p1.x), 1, filp); fwrite(&p2.y, sizeof(p1.x), 1, filp); fwrite(&p3.y, sizeof(p1.x), 1, filp); fwrite(&p4.y, sizeof(p1.x), 1, filp); fwrite(&p1.z, sizeof(p1.x), 1, filp); fwrite(&p2.z, sizeof(p1.x), 1, filp); fwrite(&p3.z, sizeof(p1.x), 1, filp); fwrite(&p4.z, sizeof(p1.x), 1, filp); fwrite(&p1.w, sizeof(p1.x), 1, filp); fwrite(&p2.w, sizeof(p1.x), 1, filp); fwrite(&p3.w, sizeof(p1.x), 1, filp); fwrite(&p4.w, sizeof(p1.x), 1, filp); } } fclose(filp);

上面的代码有几处需要解释一下:

首先我们存储的是模型坐标系中的矩阵(GetWorldTM),而不是相对于父节点的矩阵信息。在上一篇文章中我们曾提到,如果想要进行动画插值,需要计算出相对于父骨骼的变换矩阵。

其次,Tick是3dsmax中的一个时间单位,一秒钟有4800个Tick。我们导出时的时间以毫秒为单位,因此时间就是当前的Tick数除以4.8。

最后,一长串的fwrite实在是迫不得已。使用其他方式(比如)GetRow返回行时将会产生奇怪的编译错误;之前在用到Point4时也出现过问题。这有可能是由于我使用的是vs2013,而3dsmax 2012的sdk的目标工具集是vs2010导致的。

下一篇文章中我们将介绍如何在程序中计算骨骼动画并使用OpenGL显示。

参考

[1] 3dsmax模型导出插件调试技巧, http://blog.csdn.net/zhengkangchen/article/details/6424806

[2] 3DsMax导出插件编写(三)——使用IGame收集模型信息, http://blog.163.com/liweizhaolili/blog/static/16230744201311219926255/

蒙皮骨骼动画技术原理

1.前言

骨骼动画技术在计算机图形创作以及游戏开发中都占据了非常重要的位置。在骨骼动画技术出现之前,顶点动画和刚体分层动画是主要的3d动画实现手段。然而在肢体运动方面,他们都有或多或少的不足。骨骼动画则提供了较好的解决方案,因此自从《超级马里奥64》等第一批使用了骨骼动画技术的游戏诞生后,它就一直被广泛使用。

2.预备知识

在骨骼动画中,每个需要运动的顶点都被关联至某一个或多个骨骼。当骨骼的位置、方向等参数发生变化时,相关联的顶点也一同跟随其运动。而骨骼与骨骼之间的关系就像人体骨骼一样:当父骨骼发生变化时,子骨骼会发生同样的变化。因此在每一组骨骼中,除了根(Root)骨骼之外,每根骨骼都会有一个父骨骼。

骨骼与骨骼之间的节点称为Joint,或者说骨骼(Bone)是Joint之间的连接。他们在骨骼动画中可以看作是等价的概念。

这是因为所谓“骨骼”,无非是一系列的变换矩阵。从这一观点来看,Joint更适合描述骨骼动画中的“骨骼”。

接下来我们要讨论一些数学色彩稍多一点的内容,不过只要基本的线性代数的知识就足以应付了。

模型坐标系

我们在建模的时候,一般都在模型自身的坐标系中完成。在导出模型时,模型中顶点的坐标一般也以此为参考系导出;而当游戏引擎加载模型后,会使用场景管理器将其挂在场景图(Scene Graph)中合适的节点上。而在渲染时需要使用世界坐标,从模型坐标转换到世界坐标的过程将由游戏引擎来完成。

局部坐标系

大空间可能会由小的局部空间组成,如果我们为局部空间定义一个坐标系,就可以称其为局部坐标系,这是一个比较宽泛的、相对的概念。例如,在整个世界中,模型坐标系就是一种局部坐标系。如果在一个模型中,我们为更小的部分定义了一个坐标系(比如骨骼),那么它相对于模型坐标系来说,就是一个局部坐标系。

父坐标系

如果一个局部空间具有父节点,它的父坐标系就是指父节点的坐标系。

坐标变换

通常我们需要在不同的坐标系之间进行坐标变换。当一个点的坐标乘以一个变换矩阵时,我们可以认为这一个点在当前坐标系中移动了;也可以认为“点”本身没有动,是参考的坐标系移动了。也就是说,如果我们想要获得同一个点在不同坐标系下的变换矩阵,使用矩阵乘法即可获得结果。

3.骨骼动画原理

上文曾提到,模型的每一个需要移动的顶点,都通过与相应的骨骼绑定来实现。也就是说,只要我们记录下在每一帧中所有骨骼的位置、方向等信息,就可以计算出顶点的位置;如果我们在程序中每一帧都用这些信息更新顶点位置,就可以让模型动起来了。但是在此之前,我们还需要得到骨骼的“初始信息”。下面我们先暂时只考虑每个顶点仅绑定至一根骨骼的情况。

这是一种比较简单的实现方式,代价是很难进行合适的插值。下文将提供改进的方法。

Bind Pose

美工人员在绑定骨骼时,往往都在一种方便绑定的姿态下操作,这个姿态叫做Bind Pose。下图是一个比较典型的Bind Pose姿态(From cally):

bindpose

我们在导出模型时,顶点位置也往往使用模型处于Bind Pose姿态时的位置,而此时每根骨骼的姿态就是上文提到的“初始信息”。设BindPose姿态下某一根骨骼相对于模型坐标系的变换矩阵为Mb。当骨骼运动时,虽然顶点的坐标在变,但该顶点相对于所绑定的骨骼的位置并不改变(当仅绑定至一个骨骼时)。因此,对于每一个顶点,我们需要进行以下变换:

BindPose姿态下的坐标(模型坐标系)→局部坐标(骨骼局部坐标系)→当前帧坐标(模型坐标系)

首先我们先来解决第一步转换。我们曾记录了Bind Pose姿态时骨骼的姿态Mb,利用Mb,我们可以将局部坐标转换为BindPose坐标,即:

vL * Mb = vb

其中vL为局部坐标,vb为Bind Pose坐标。

如果读者不理解为何使用Mb可以将局部坐标转换为BindPose坐标,可以做以下的推理:

上文中曾提到,“当一个点的坐标乘以一个变换矩阵时,可以认为这一个点在当前坐标系中移动了;也可以认为‘点’本身没有动,是参考的坐标系移动了”。设想一个点从模型空间原点p(0, 0, 0),移动到BindPose姿态时骨骼所在的位置p’:

p * Mb = p’

那么如果另一点q处于骨骼的局部坐标系中,其坐标为q(0, 0, 0),希望将其变换为模型坐标系中的坐标q’,实质上就是将骨骼局部坐标系移动至模型坐标系,即

q * Mb = q’

因此如果我们想将Bind Pose坐标转换为骨骼局部坐标,需要以下公式:

vb * Mb-1 = vL

也就是说,我们只需要记录Mb-1即可。在第二步转换中,变换矩阵就是骨骼在当前第i帧的位置Mi(模型坐标系),即

vL * Mi = v

因此我们所需要的信息只有两个:1.每根骨骼的BindPose矩阵的逆。2.每根骨骼在每一帧的矩阵。(它们都是相对于模型坐标系的。)只要有这两个信息,我们就可以计算出每个顶点在每一帧的位置了。

在这一小节结束之前,还有一点需要补充:所谓Bind Pose姿态,不过是一个“基准”。其实任何一帧的姿态都可以成为“基准”,即只要在导出时模型的顶点位置与骨骼的变换矩阵在同一姿态下即可。

4.骨骼动画的插值

上述方法很难对动画进行合理插值,这就失去了骨骼动画的一大优势。其原因在于我们所使用的M矩阵是相对于模型空间的。如果仅依此对矩阵进行插值,有时很难的到合理的结果,容易出现错位、交叠等现象。

解决这个问题的办法是使用相对于父骨骼的矩阵Mr(即相对于父坐标系)。当前骨骼通过Mr得到父坐标系下的坐标,其父骨骼再通过它的Mr得到父亲的父坐标系下的坐标……直到计算到相对于模型坐标系的坐标。如下图(From* Game Engine Archtecture*):

matrix

即上文中的公式将变成:

vL * Min * Mi(n-1) … * Mi0 = v

由于此时每个骨骼将相对于父骨骼进行运动,因此插值结果将更加合理。不过变换矩阵的插值算法超出了本文的范围。

5.将顶点绑定至多个骨骼

当需要将顶点绑定至多个骨骼时,需要为每个骨骼设置一个权重,代表该骨骼对该顶点的影响程度。计算时每个绑定的骨骼的都按照上述计算方式进行计算,最后根据权重计算最终的顶点坐标即可。

6.总结

本文简要介绍了蒙皮骨骼动画的基本技术原理。下一篇文章将介绍如何编写3dsmax插件导出骨骼动画,并使用OpenGL在我们的程序中加载。