第一回 开篇 D3D渲染流程简介
开发这个3D
engine已经两年半了,从06年8月刚开始统计的4万多行,到今年7月份的21万多行,有一些感慨,感觉有那么点成就感,不过更多的是惴惴之心:这些代码可以很好的在一起工作吗,足够快吗?bug肯定不少,因为测试的强度毕竟不高,这还不是最重要,架构上会不会有致命的疏忽的地方?会不会在未知的需求面前不堪一击?前些日子看了看两年前写的代码,觉得不满意的地方很多,代码写出来的一瞬间就开始贬值,两年多时间,也的确该贬得差不多了,有心要
重写,似乎又没多少时间,接下来还有很多事情要做,而自由宽松的开发环境不是能永远维持下去的.还有一些担心:我是否真的尽力了呢?完成这些功能,是否真
的需要这么长时间呢?它们是否真的有价值呢?不仔细回忆,都有点想不起来一年前都干了些什么.虚度光阴?有这个可能--往消极的一面想,还真能想出不少东
西.不过要积极点呢,也有不少值得欣慰的地方,两年来至少学到了不少东西,对3D图形方面的经验有了些积累,不像两年前那么生疏了,脚本也接触了一些,编辑器写得更熟练了,外观也更漂亮了(这个很关键,写程序不就是为了写点好看的东西吗?),模板也用得比以前熟练多了,有些问题的解决方法自己也挺满意...二十多万行垃圾代码里,居然也有那么些闪光点,若隐若现,叫人安慰.
所以我打算从这个炎热的夏天开始,关于这个engine写点什么,一来可以留作纪念,二来可以整理下思路,并做将来的备忘,三来可以把自己的一些想法公布
出来,用以交流,不敢说高台教化,起码是劝人向善,教人学好(呵呵,怎么和郭德纲一个口风),如果能有人看到这些东西,受到些启发,也是很好的事.
先从最基础的写起吧,关于Device的渲染流程.D3D9的Device就是D3D给我们提供的一个绘制3D图形的工具,它的绘制流程大致是这样的:
首先Device的使用者要准备好顶点数据,也就是一个顶点的数组,称为A
然后这个数组A被传入device的渲染管线
device内部依次对每个顶点进行处理,有两种模式,固定管线和shader模式,所谓固定管线就是device内部实现的一个固定的程
序,用户只能通过设定各种参数(一些RenderState)来控制它,当然这不够灵活,所以有了shader模式,也就是说,用户需要写一个程序片段
(所谓vertex shader),传给device,然后device使用这个片段对每个顶点进行处理.这个程序片段是在显卡上执行的.
传入的顶点数组A的每一个元素被转换后,存储到另一个数组B中.数组B中的每个元素必须至少包含一个透视空间的位置,用来做裁剪.
数组B被传入到device的下一个计算阶段,在这个阶段里,数组B中的(被转换过的)顶点被组织成一个个三角形,然后对这些三角形进行裁
剪(利用顶点数据里包含的那一个透视空间的位置),背面剔除(注意背面剔除和顶点的法线是没关系的),最后剩下的三角形被保存到一个数组C中.(注意在这
个阶段里顶点数组变成了三角形数组)
数组C被传入到下一个计算阶段,光栅化,对于数组C中每一个三角形,首先把它们从透视空间映射到屏幕空间,然后找出它们在屏幕上覆盖的像素
(一个三角形覆盖的像素的数量有可能是很多的),对于每一个像素,根据它在三角形中的位置,通过三角形的顶点进行线性插值,计算出一个像素数据(注意像素
数据是通过三角形的顶点数据插值而来,所以它们的数据类型是一致的),所有三角形算出来的像素数据最后被存储到一个数组D中.(在这个阶段里,三角形的数
组变成了像素数据数组)
数组D被传入到下一个计算阶段,在这个阶段里,device会对这些像素做一些初步的过滤,主要是进行stencil
test(根据stencil buffer上的值 )和z-test(根据这个像素的Z值和z-buffer上的值进行比较),根据测试结果会对
stencil buffer进行一些修改(使用一组render state来控制这个过程),通过这些test的像素被存储到数组E.
数组E被传入到下一个计算阶段,在这个阶段里,device对每个像素数据进行处理,这个阶段也有两种模式,固定管线和 shader模式,
与顶点处理阶段类似,用户也可以写一个程序片段,来对每一个像素数据进行处理,称为pixel
shader.像素数据可能包含各种类型的数据,但经过这一阶段的处理后,输出是很简单的,一般就是一个颜色值和一个alpha值(透明度),也可以输出
一个Z值,不过好像不常用.在pixel shader里还可以使用专门的指令来放弃某一个像素的后续处理.所有的像素数据被处理后,结果存在一个数组F中.
数组F进入下一个阶段,在这一个阶段里,进行alpha test(根据像素的alpha 值),alpha test是最后一个test了,通过了alpha
test的像素可以保证绘制到屏幕上去,通过test的像素会把它们的z值更新到z-buffer中去(具体由一组render
state控制),通过test的像素被存入数组G
数组G进入下一个阶段,在这个阶段里,主要是把数组G里的像素和屏幕上已有的像素进行混合,具体混合的方式有多种多样,由一系列render
state进行控制.混合以后的像素就被"画"到屏幕上了
基本上的流程就是这样了,环节很多,每个环节也有很多技巧,很多需要注意的地方,太多了,就不铺开来写了,我觉得这个流程很重要,虽然理解起来
并不困难,但真的能够非常熟练的记住它,并且运用其中的各个环节来解决实际的问题就不是那么容易的事了.所以不厌其烦的又把它粗粗的写了一遍,希望自己也
能进一步加深印象.
第二回 关于vector和deque
vector是我最早用的stl容器,用得也最顺手,它的结构也是清晰易懂.deque就比较神秘一些,帮助上提到它的一个最显著的特点就是可以从容器的
前端插入成员,而且效率很高.当时觉得很神奇,不知道是怎么做的.stl的源代码看起来太痛苦了,所以到今天也没准确的了解它的结构.不过后来用的多了,
对它的习性也有了一定的了解,有些地方相对于vector来说其实是很有优势的.下面就把两者对比着说说:
先说内部结构.vector就是一块连续的内存,这块连续的内存会随着成员的添加而不断的re-alloc,而且在重分配的时候,分配的内存的大小会
比实际需要的多一些,下次再添加成员时,就可以添加在这些多余的空间里,而不会导致每添加一个成员就需要重分配内存.vector封装的是一块连续的内
存,这是我最喜欢它的地方,因为可以把它的成员直接转换成指针来进行访问,很灵活.
deque可以根据一个索引进行随机访问,所以我一度也以为它内部有一块连续的内存,直到有一次我真这么干的时候把程序搞当了.才意识到这个错误.deque内部应该是由很多定长的内存块组成的链表,这是我猜的,因为似乎只有这种结构才能和它的表现相符.
往vector,deque里添加数据应该都是很快的吧,毕竟这是这两个容器的卖点.这个我没有具体测过.
vector的遍历速度是很快的,应该是到极限了,不管你用iterator来遍历还是用一个递增的下标进行访问,经过编译器的优化都可以有最高的效率.
deque的遍历速度也不慢,如果使用iterator来遍历,可以有接近于vector的效率,但如果直接用递增的下标进行遍历,好像编译器无法优化至最高效率,好像慢一倍左右:
std::deque buf;
std::deque::iterator it;
int sum=0;
for (it=buf.begin();it!=buf.end();it++)//这样遍历比较快
sum+=*it;
for (int i=0;i
sum+=buf[i];
vector内部分配的内存是永不释放的,即使你调用clear()也不会,这一点很不好,有误导性.有可能一个vector只在瞬间需要很大的容
量,但大多数时间只需要很小的容量,结果却是长时间的占用了很大的,没有被使用到的内存.vector也没有提供函数来释放它内部的内存,不过有一个简单
的办法,前几天在网上找到的:
i_math::vectorbuf;
buf.resize(100000);//分配了一块至少100000 bytes的内存
if (TRUE)//清空buf的内存
{
i_math::vector t;
buf.swap(t);//把这块内存交换到一个临时的vector里去
}
assert(buf.capacity()==0);//内存被清空了
deque就不一样了,deque永远不会占用太多冗余的内存,你只需要把它resize()到一个你希望的大小,它会自动释放掉那些被多余占用的内存
vector还有一个不好的地方,当你往一个vector里添加一个成员的时候,所有指向这个vector的原来成员的指针就不能保证有效了,因为
vector会re-alloc内存.而deque不会,无论从前面还是后面添加新成员,旧的成员都不会移动位置,这一点有时候很有用.
所以我觉得deque其实在很多地方都有优势,比起vector,它欠缺的就是内存不连续,使用起来不够灵活,但它对内存使用的更经济,而访问的效率也比
vector慢不了太多,当然,它还能从前面快速插入删除,这是压倒性的优势.总之,deque是在关键时候能帮上忙的那种,平时可能还是vector更 好用一些.
最后顺便说说list,我一点也不喜欢list,几乎没怎么用过,往list里添加成员是很慢的(相对与vector,deque),好像每添加一个成员
都要分配一次内存,它的遍历也很慢,好像就比map的遍历快一点,不能随机访问.唯一的优势就是可以在容器中间插入删除,不过我觉得都是可以用
vector/deque加上一些技巧解决的。反正我在程序里很少碰到过非用list不可的情况,也许是我写的程序类型还不够多吧呵呵.
ps.我用的是stlport,vc的实现应该也差不多吧.
第三回 运行时类信息(Runtime Class Information)
刚开始学习MFC的时候,就碰到有关于RunTime Class
Info的东西,可以在运行时得知一个对象的指针到底是什么类型的.当时并没有深入研究,用得也不多,只是觉得挺神奇的.而在这个engine的开发过程
中,才渐渐觉得这是个好东西了.我在engine里实现了一套这样的系统,用着还蛮顺手的,在这里介绍一下. 基本上这套系统(Runtime Class
Information System,暂时简称为RtCIS吧)的功能有:
最重要的,就是可以为使用
RtCIS的类提供一个统一的内存分配器,所有这个类的实例全部从这个池中分配,而因为这个内存分配器是针对单一类型的数据的,所以可以写的比较简单而有
效率(使用模板),并且,可以用类的名字来标识这个内存分配池,这样可以方便内存池输出一些调试信息.
由于这个类的实例是分配在一个统一的内存池里的,所以可以方便快速的枚举出这个类的所有实例,这在有时候是很有用的.
可以根据类名(一个字符串)在运行时创建一个类的对象
可以在运行时来判断一个对象属于什么类型
这套系统的主要是通过在一个类中添加静态变量方法来实现的,下面列出一套精简版的实现,engine中的实现会复杂一些,包括一些其它的功能,比如记录类
的继承信息,枚举类的实例,为每个类分配一个ID,根据这个ID来创建实例等,这些功能全部可以在以下代码的基础上扩充,就不列举了.
首先定义一个类信息的基类:CClass
class CClass
{
public:
virtual void *New()=0;
virtual void Delete(void *)=0;
const char*GetName() { return _classname.c_str(); }
static void *NewByName(const char*clssname)
{
return _classes[std::string(clssname)]->New();
}
protected:
void SetName(const char *name)
{
_classname=name;
_classes[_classname]=this;//将自己注册到全局的类信息表格中去
}
std::string _classname;//类的名字
static std::map
};
然后定义一个宏:DECLARE_CLASS(),用来在类里声明RtCIS,(为清晰起见,省略了行末的\ )
#define DECLARE_CLASS(clss)
public:
//类信息,派生自基类CClass
class CClass_##clss:public CClass
{
public:
virtual void *New() { return _pool.Alloc();}
virtual void Delete(void *p) {_pool.Free((clss*)p); }
CMemPool _pool;//这个类的内存池
};
static CClass_##clss *_instantiate()
{
static CClass_##clss instance;
instance.SetName(#clss); //设置类的名称,并注册到全局类信息表格中去
return &instance;
}
CClass *GetClass(){return _class;}
static CClass_##clss *_class;//静态变量
然后再定义一个宏,用来实现RtCIS:
#define IMPLEMENT_CLASS(clss)
clss::CClass_##clss * clss::_class=clss::_instantiate();//定义并初始化静态变量
*.再定义几个宏用来方便的使用这套系统:
#define Class_New(clss) (clss*)(clss::_class->New())
#define Class_Delete(p) (p)->GetClass()->Delete(p)
#define Class_NewByName(clssname) CClass::NewByName(clssname)
#define Class_GetName(p) (p)->GetClass()->GetName()
下面是一个例子,关于水果的:
在头文件里:
class CFruit
{
public:
virtual CClass*GetClass()=0;
//...
//...
};
class CApple:public CFruit
{
public:
DECLARE_CLASS(CApple);
//...
//...
};
class COrange:public CFruit
{
public:
DECLARE_CLASS(COrange);
//...
//...
};
class CGrape:public CFruit
{
public:
DECLARE_CLASS(CGrape);
//...
//...
};
在cpp文件里:
IMPLEMENT_CLASS(CApple);
IMPLEMENT_CLASS(COrange);
IMPLEMENT_CLASS(CGrape);
实际使用:
int main()
{
CFruit* fruit1=Class_New(CApple);
CFruit* fruit2=Class_New(COrange);
CFruit* fruit3=Class_NewByName("CGrape");
assert(std::string("CApple")==Class_GetName(fruit1));
assert(std::string("COrange")==Class_GetName(fruit2));
assert(std::string("CGrape")==Class_GetName(fruit3));
Class_Delete(fruit1);
Class_Delete(fruit2);
Class_Delete(fruit3);
}
另外,这两天在看havok的物理引擎,它的对象分配也采用了类似的方法,并且有一个比较好的特性就是对于某一个类,可以在每一个线程里为它配备一个独立的内存分配器(这样就可以避免在内存分配代码里加锁,有助于提高效率),比如
void thread1()
{
CFruit* fruit1=Class_New(CApple);
Class_Delete(fruit1);
}
void thread2()
{
CFruit* fruit2=Class_New(CApple);
Class_Delete(fruit2);
}
fruit1和fruit2是自动从两个独立的内存池里分配出来的,我还没看havok是怎么实现的,不过应该不会太麻烦.希望将来有时间加这个
feature.
第四回 关于多线程渲染
困扰了一个多月的问题,今天终于有个阶段性的了结了,虽然不知道算不算真正的了结.
多核的cpu现在是大势所趋,渲染是一个很费时的活,所以应该考虑考虑能不能利用多核来提升这部分的性能.
引擎一开始没有在多线程方面作任何的考虑,因为我从来就不喜欢多线程,这方面的思考能力不强,而且一开始写个单线程的engine已经够费事了,要加入多线程的设计对我来说实在是太难了.但是在积累了这么多时间的经验以后,我开始考虑加入多线程的支持,这玩意对架构的影响很大,所以加入设计还是宜早不宜迟.一开始先加入了多线程载入资源的模块,也顺便恢复一下早已荒废多年的多线程编程技能.接下来就该考虑将渲染部分放入一个单独的线程中去了.
首先,为什么呢?为什么要把渲染部分放到一个单独的线程中去呢?有什么好处呢?我的理解是这样的:显卡可以看成是一个外设,渲染的过程就是cpu不停的给
gpu发各种命令,根据d3d的文档上说,d3d内部有一个command buffer,cpu在调用各种d3d的api时,其实是往这个command
buffer里添加命令,这个command
buffer是有一定大小的,当这个buffer满了以后,会有一个flush过程:这个buffer里的命令会被一齐扔给gpu去执行,然后这个
buffer会被清空,以接受新的命令.按照d3d的文档上说,这个flush过程是很慢的,它必须要等待这个command
buffer里的命令全部被gpu执行完,才会返回,不过我自己测了测,似乎不完全是这样,会有等待,但等待的时间好像并不是全部的执行完这些命令的时间,具体为什么,我也搞不清楚,也许写显卡驱动的人会更了解一些吧,d3d对我们这些写应用程序的人来说就是黑盒子.不过阻塞是一定会有的.所以我认为可以把gpu连同D3D看成一个类似硬盘的io设备.cpu通过调用d3d的api来给这个设备发命令,大多数情况下,这些api立即就返回了(这些命令被
cache在d3d内部的一个command buffer里),但是偶尔的,当一个api调用试图在一个已经满了的command
buffer里再加入命令时,就会触发一次d3d内部的flush过程,而这个时候,这个api调用就必须等待这个io设备了,也就是cpu这个时候是空闲的,计算能力在这里被浪费了.很多关于d3d的教材都提到不要调用太多的draw
call,应该把多个draw call合并在同一个batch里,我以前以为,可能是因为d3d api的调用本身有开销,我现在的理解是,draw
call太多,会导致太多的d3d api调用,太多的命令,而这样会导致d3d内部的command
buffer频繁溢出,从而导致d3d内部频繁的flush,加剧了cpu等待gpu的情况.
如果d3d的内部运作模式真是我上面描述的那样的话,把渲染部分放到一个单独的线程中去就显得有必要了,因为可以把cpu在等待gpu的时间充分利用起来,或者至少不会让这些等待耽误到主线程的运行.
接下来,就要考虑怎么来做了.显然,这是一个典型的producer-consumer的结构,主线程(procucer)将要执行的渲染命令不停的加到一个队列里,再开一个渲染线程(consumer)不停的从这个个队列里读取命令,并执行.一开始我主要考虑要减少这些命令的个数,这样实现可以简单些,效率可以高些,所以打算把我自己实现的整个渲染系统放到一个单独的线程中去,把原来的接口全部转换为可以被队列化的一个个命令.干了一段时间后,发现实现难度还不小,接口数量还是太多了,所以又打算把接口提升到更高的层次中去,也就是把整个场景的渲染模块放到一个单独的线程中去,这个渲染模块主要包含了渲染对象的场景管理以及渲染分类.这样做的确减少了接口数量,所以虽然比较艰辛,我终于几乎还是把它给实现了,不过就在接近实现的时候,我又把整个问题重新考虑了一下,发现似乎又走了弯路.
我的想法是这样:
对于目前大多数的游戏,每一帧的运算主要包括两部分:渲染和逻辑.
对于3D游戏,gpu完成渲染部分,cpu完成逻辑部分.
渲染部分是非常耗时的,一般来说比逻辑部分的消耗的时间要多
发展的趋势是cpu朝多核方向发展,逻辑部分可以被分到多个线程去做.
XBOX360 已经有6个核了.
gpu 也会发展,但是目前的即时渲染的水平离电影级的渲染水平仍然有很大的差距.
所以,相当长的时间内,渲染的耗时仍然会是瓶颈
而目前的D3D的架构只允许使用一个单独的线程去驱动gpu
所以不应该让这个单独的线程做额外的工作了,也就是渲染线程应该越单纯越好,
而最最单纯的就是在这个渲染线程里只做一件事情,就是给D3D发命令.
所以最后我采取了这样的方法,我把所用到的D3D接口函数全部重新写了一下,在这些函数里,我把原来对d3d的调用全部转化成一个个的命令,加到一个队列里,而由一个单独的渲染线程从这个队列里读取命令,再把它们传递给D3D.这样做还有一个附加的好处就是,它对整个引擎的架构的影响很小,所有的功能被局限在一个集中的模块里,并且可以很方便的enable/disable,很方便的在多线程/单线程渲染这两者之间切换,便于对比调试.而且如果这个模块写得完美,甚至可以开放给公众使用. Threading the OGRE3D Render System(by Jeff
Andrews),这篇文章也提到过这种方法,它提出了三种线程化的方案,主要也是按照渲染线程所处的层级来分的,和我上面说的比较类似,并且讨论了不少实现细节.不过它似乎认为在最高层次上进行线程化可以最大限度的发挥多核的威力,我想他可能是站在cpu计算瓶颈的角度上来看的吧.作者也为在最低层次上线程化的方案(重写D3D接口)作了实现(有源代码下载,我参考了一下),并做了测试,但效果似乎并不那么显著.不过他说it‘s
doable.
实现这套接口对我来说并不是件容易的事,先是磨磨蹭蹭花了一周才写完,测了一下,结果很糟糕,在同事的双核电脑上比单线程版本要慢很多,以我贫乏的多核应用经验,一开始我以为是cpu的问题,后来在vista上测试的时候,发现了不错的结果,多线程版本比单线程版本几乎快了一倍(说实话,我第一次对
vista有了好感),但是xp下就不行,进而怀疑操作系统的问题,下载了一个多核性能的bench
mark,结果一切正常,问题看来还是在自己身上.最终还是找出了问题所在.目前的这个测试用例在双核的电脑上的有不小的性能提升,执行时间大概是单线程版本的60%左右,在单核的电脑上性能有微小的下降.还没有用更多的例子测过,所以不一定能说明问题,但至少说明是有前途的,是doable的.
实现过程中有一个地方值得注意,就是vertex
buffer的Lock()/Unlock()的处理.上面说的这篇文章里也重点提到了这个问题,并提出了两种解决方法,Partially Buffered
Locks和Fully Buffered Locks.我的方法有所不同,我将vb的lock分为三种情况:
static 的vertex buffer,通常这种vb用来存储不会发生变化的vertex
数据,一般只在初始化的时候需要lock()/unlock(),对性能的影响不是很大,所以在lock时,会对command
buffer进行一次flush,也就是说主线程等待渲染线程将当前command buffer里的所有命令全处理完后,才进行lock()/unlock()
dynamic 的vertex buffer,no overwrite的lock(),所谓no overwrite的lock,就是lock
vb后,使用者可以保证不去覆写那些已经被用到的vertex数据(这些vertex的数据可能正在被gpu用来绘制),在这种情况下,可以比较简单的处理,只要暂时冻结渲染线程的处理,然后进行lock就行了,然后在unlock后,再恢复渲染线程的处理.
dynamic 的vertex
buffer,discard的lock(),这种lock将会丢弃原来vb中的所有内容,这时候显然不能简单的对这个vb使用discard标志进行
lock,因为使用这个vb进行绘制的命令可能还在command
buffer里,等待渲染线程的处理.我一开始的解决方法是:先冻结住渲染线程的执行,然后添加一条release这个vb的命令到command
buffer中去,然后再创建一个新的dynamic的vertex
buffer,并对它进行lock,返回lock的数据指针,当unlock后,再恢复渲染线程的执行.后来做了些优化,因为在nVidia的卡上,创建一个vertex
buffer似乎对性能有很大的影响,所以我改为使用一个vertex
buffer的pool,vb不会被真正release掉,而是扔到这个pool里,当需要新的vb时,再从这个pool里分配.这样可以使性能开销降到最低.
在实际应用中,情况2应该是最常使用的,它的性能开销也是最小的.只需要要锁一下渲染线程.
对于texture/surface的lock()/unlock(),似乎没有很好的方法,必须flush一下command
buffer.好在这种情况并不会太频繁的出现.而偶尔flush一下command buffer也不是那么的不可忍受.
顺便提一下多线程编程的一个注意点,A线程访问变量a,同时B线程访问变量b,如果a和b这两个变量在地址空间上离的很近的话,是会降低性能的,不能做到真正的同步访问,所谓false
sharing.要避免这种情况,这两个变量要分配在不同的内存段上面,内存段的长度可能和硬件有关系吧,一般比如说128个字节,不放心的话,再远一点.
第五回 对象描述信息(Object Description)--使对象使用起来更方便
一个c++项目里会使用各种各样的对象,但对象的使用往往并不是件轻松的事,当用一个c++类去封装一个对象的时候,程序员往往要做以下这些事
对象的成员变量的初始化,一般在构造函数里实现
对象的内容的清除,一般在析构函数里实现
对象的复制,把一个对象赋值给另一个对象,一般要实现一个operator =(..)
对象的比较,最常用的比较就是比较两个对象的内容是否一样了,一般通过实现operator==(..)来实现
对象的序列化,也就是存储/读取的工作,一般需要为这个对象实现Save()/Load()的函数.
对象序列化的时候,一个比较讨厌的问题就是格式兼容问题,当你修改/添加/删除了这个对象的一些成员变量后,旧有的存储数据就与当前的格式不兼容了,这就需要存储时多存储一个版本号,并在读取的时候作判断,来保持格式的兼容
此外,如果用户需要对这个对象进行编辑(这在3D
engine中是很普遍的),那就会比较麻烦了,你可能会需要写一个编辑对话框,为每一个对象的成员变量加一个控件,把这些控件排版成好看的样子(不过我要说这也是件很有成就感的一件事),然后写一大堆消息处理函数,来对对象进行编辑.
如果仅仅针对某一个类做这些事情,其实并不算太痛苦的事,但当类的数量急剧增加时,这就变得越来越难以忍受了.在相当漫长的一段时间内,我都忍受着这些痛苦,机械地实现着这些没什么技术含量,但相当繁琐的代码--直到在开发这个引擎过程中,我终于考虑比较彻底的解决这个问题.
我看过一些其它的engine的代码,我发现一种比较常用的方法,就是用一个脚本文件来定义你的对象(比如说unreal),然后在脚本的分析代码里,提取出这个对象的各个成员变量的描述信息(比如类型,初始值,编辑方式等),然后再通过这些信息使用统一的方式来完成种种的操作.我没有能力写一个像
unreal那样的有效率的虚拟机(曾经想过),所以最后还是使用c++的方式来实现了.
实现的思路基本上是这样,在写一个c++类的时候,除了定义各种成员变量以外,再为每一个变量定义一套额外的描述信息,这些信息以这个类的静态变量的形式存放.这些描述信息主要包含:
这个成员变量在对象中的相对位置,也就是相对于对象起始地址的偏移量
这个成员变量的名字及文字描述.
这个成员变量的一个版本号
这个成员变量的语义(sementic),决定了编辑器对它的编辑方式
以及,最关键的,这个成员变量的操作器,操作器为某种特定类型的变量提供各种操作,比如初始化,清除,复制,Save/Load,以及编辑手段等.常用的变量类型有:
简单变量,这个变量的所有内容可以被包含在一块连续的内存中.
简单变量的定长数组
简单变量的变长数组
字符串
对象
对象的定长数组
对象的变长数组
等等...
下面这个例子列出了这些变量类型:
class CSubObj
{
public:
int v1;//简单变量
int v2[20];//简单变量的定长数组
};
class CObj
{
public:
POINT v3;//简单变量
std::vector v4;简单变量的变长数组
std::string v5;//字符串
CSubObj v6;//对象
std::vector v7;//对象的变长数组
CSubObj v8[10];//对象的定长数组
};
对于这些变量类型的各种操作彼此不同,所以,需要为每一种变量类型写一个对应的操作器,比如对于简单变量的变长数组, 它的各种操作是这么实现的:
初始化:已经由vector的构造函数完成了,不需要做任何事
清除:调用vector的clear()函数
复制:调用vector的assign()函数
比较:比较两个vector包含的内存块
Save/Load: 将vector包含的内存块作save/load.
编辑:写一个编辑数组的控件(比如写一个list box,并且提供编辑每一个item的功能)
可以为每个成员变量都添加上述的这些描述信息,最后一个类的所有成员变量的描述信息被记录在一个链表里,这个链表的头记录在这个类的静态成员变量里.有了这些信息,你就可以使用一些统一的方式来对一个对象的实例(实际上是对这个对象实例的各个成员变量)来进行各种操作了.
下面是引擎中这套系统的一个例子,希望你能够通过它对这套系统的使用方法有个直观的了解: 首先有一组宏,这些宏用来定义对象描述信息
// 定义对象描述信息开始
BEGIN_OBJ_DESC(classtype,version)
// 以下这些宏分别对应了成员变量的各种操作器:
GELEM_VAR_INIT(type,name,initv)
GELEM_STRING_INIT(name,initstr)
GELEM_VARVECTOR_INIT(type,name,initv)
GELEM_VARARRAY_INIT(type,name,initv)
GELEM_STRINGARRAY_INIT(name,initv)
GELEM_OBJ(type,name)
GELEM_OBJVECTOR(type,name)
GELEM_OBJARRAY(type,name)
// 以下下定义一些额外的信息
GELEM_VERSION(version)// 成员变量的版本号
GELEM_EDITVAR(name,vartype,varsem,desc)// 成员变量的编辑相关信息
GELEM_EDITOBJ(name,desc)// 对象成员变量的编辑相关信息
// 定义对象描述信息结束
END_OBJ_DESC()
然后就可以使用这些宏来为一个对象定义它的各个成员变量了,以下是一个例子,屋子里面放椅子
struct Chair
{
DWORD color;//椅子的颜色
int nLegs;//有几条腿
BOOL bBackrest;//有没有靠背
// 对象描述信息
BEGIN_OBJ_DESC(TestData,1);
GELEM_VAR_INIT(DWORD,color,0x0000ff);// 初始化为蓝色
GELEM_EDITVAR(" 颜色",GVT_UNSIGNED,GSem_Color,"椅子的颜色");
GELEM_VAR_INIT(int,nLegs,4);// 有4条腿
GELEM_EDITVAR(" 腿的数量",GVT_SIGNED,GSem_Number,"椅子腿的数量");
GELEM_VAR_INIT(BOOL,bBackrest,1);// 初始化为有靠背
GELEM_EDITVAR(" 靠背",GVT_SIGNED,GSem_Boolean,"椅子是否有靠背");
END_OBJ_DESC();
};
struct Room
{
std::vector chairs;//屋里的椅子
std::string ownername;//屋子主人的名字
// 对象描述信息
BEGIN_OBJ_DESC(Room,1);
GELEM_OBJVECTOR(Chair,chairs);
GELEM_EDITOBJ(" 椅子","屋子里的椅子");
GELEM_STRING_INIT(ownername,"Jack");// 注意没有为这个变量定义编辑信息,所以它不会出现在编辑窗口中
END_OBJ_DESC();
};
有了以上这些定义,就可以方便的使用这些对象了, 比如:
void main()
{
// 初始化
Room room1,room2,room3;
assert(room1.area==SIZE(20,10));
room1.chairs.resize(4);
for(int i=0;i<4;i++)
assert(room1.chairs[0].color==0x0000ff);
// 比较,赋值
assert(!(room2==room1));
room2=room1;
assert(room1==room2);
// 存储,读取
room1.GSaveToFile("room1.bin");
room2.GLoadFromFile("room2.bin");
// 清除对象数据
room1.GClear();
assert(room1==room3);
}
注意:上面的GSaveToFile(),GLoadFromFile(),GClear(),以及operator=(),operator==()是由
BEGIN_OBJ_DESC()/END_OBJ_DESC()这两个宏在对象里添加的函数
此外,可以在对象的存储信息里包含额外的控制数据,使得可以很方便的在对象里增加/删除/修改成员变量,而保证原来的存储数据仍旧可以正确的读出.
最后,可以使用一个通用的编辑窗口来显示/编辑这些对象,如下图:
我想我并没有把这套系统的实现细节讲得很清楚,除了思路,我只是列出了这套系统的最终使用方法,希望你能看得明白.出于保密原因,我也不方便贴出源代码,不过我想有时候知道做什么往往比知道怎么做重要的多,我的实现并不是很优雅(有1500多行代码,大多数是模板类和宏),描述信息的定义也略显笨拙,希望能有更加完美,更加简洁的实现.不过这种类型的系统的确可以大大简化对象的使用和编辑,减少出错的概率,使得应用程序可以将大规模的对象直接暴露给用户,而这在没有这套系统的情况下是不太可能的.
最后记录一下目前的实现的一些优点和缺陷:
(优点)可以为任何对象添加描述信息,而不要求这个对象派生自一个固定的基类(有一些实现好像要求这么做)
(优点)使用起来比较方便,只需要include一个头文件
(缺点)存储时为了控制版本信息,数据量有些大,读取时的效率也不是很高
(缺点)目前并不支持所有的变量类型,比如map,list等,当然可以避免使用它们.
想不出来了,想到再加.
第六回 使用数值曲线表示动画
这一阵子在写关于角色行为控制的东西,这首先离不开一套动画更新机制。
所谓动画,可以看作是一根随着时间变化的数值曲线,要描述一根曲线有很多种方法,最普通的可能就是用一个数学公式来描述,比如说sin曲线,不过在互动性很强的游戏里,曲线往往不会那么理想化的简单,通常需要由很多参数来控制。比如说一个角色沿着一条路径在走的时候,它的空间坐标就是在一根曲线上,这根曲线由一系列控制点以及一个控制方式来决定.简单的,曲线可以由这些控制点的连接线构成,复杂一点,可能会用到一些2次或者3次的贝塞尔曲线.角色行走的路径可能会随着用户的输入而改变,我们所要做的就是根据用户的输入来修改这条曲线,或者说,修改这条曲线的控制点.
再比如骨骼动画,这个看上去有些复杂,不过它的本质还是一样的,骨骼动画中变化的数值比较多,包括每根骨头在它的parent空间里的旋转,位移,和缩放,如果一套骨骼有50根骨头组成的话,那将会有150个变化的数值,或者说150根曲线,当然也可以看成是一根曲线,它的值是一个有150个分量的向量.骨骼动画的控制方式也比较特殊,本质上它仍然是一套控制点的机制,只不过它的控制点是成套的.比如现在有一套50根骨头组成的骨骼,当我们希望这套骨骼播放一段由
32帧组成的挥手动画时,其实我们是为这150根曲线的每一根添加了32个控制点,而这150*32一共4800个控制点是由美术事先设定好的.
所以,游戏里的动画系统可以由许许多多,各种类型的数值曲线组成,逻辑层处理各种用户输入,网络消息,来决定如何修改这些曲线.然后我们需要一套显示系统,得到这些曲线在某个时刻的值,在屏幕上画一些东西来把它们体现出来.
NOTE:我们在更新动画的时候,不是去修改一些具体的值,而是去修改一条(或多条)曲线.这使得动画更新变得非常的轻量级,不需要耗费很多的计算.而把耗时很多的曲线采样留到真正需要它的时候进行,比如说绘制的时候。而对于那些不在屏幕之内的动画,或者某种原因不用显示(比如隐身了),我们就完全不需要为它而进行复杂的插值运算了.
动画曲线可以很好的解决动画组合的问题,典型的例子就是一个角色沿着一条路径行走(我们不妨称它为角色行走动画,记为A),这个动画过程由两个独立的动画组合而成--
路径动画(B)和角色骨骼动画(C),角色行走动画,我们可以把它看成是一根曲线(它的数值由一个交给蒙皮系统的matrix数组组成),这根曲线依赖于两根曲线--路径动画曲线,它的数值为一个位置加一个旋转,以及骨骼动画曲线,它的数值也是一个matrix数组(位于角色局部空间内),在初始化时,我们先设定好这几根曲线的依赖树:
然后在逻辑更新(动画更新)的时候,我们需要做的是:1.计算这个角色的下一个路点,把它作为一个新的控制点加到路径动画曲线中去
2.决定这个角色此时要做什么动作,选一个动画(一套预先设定好的控制点)加到骨骼动画曲线中去.再次注意:在动画更新中,我们不需要在曲线上做十分费时的采样计算。
好,逻辑更新完了,现在到了显示的时候了,比如在时刻t,我们要在屏幕上画一帧,我们需要得到曲线A在t时刻的值,这个值依赖于B和C在t时刻的值,所以曲线B和
C会先被采样,得到的值经过计算后作为曲线A的值返回.这个值被送到蒙皮系统中用来绘制角色模型.
从上面的例子可以看出,我们可以通过建立起动画曲线的依赖关系来构建比较复杂的动画系统.这些动画曲线可以被封装在不同的独立的模块中,并通过简单的接口来彼此联系.比如现在在这个行走着的角色身边还有一个角色,他的视线被锁定在这个角色身上了,怎么来实现呢?我们可以在这个旁观者角色上实现一个眼球转动动画,记为D,并且把它和曲线B联系起来:
比如我们又希望在这个行走角色的手上拿一把刀,那我们就需要另一个额外的动画曲线了--角色手部移动动画,记为E,这颗动画依赖树会变成这样
(具体的曲线E的采样方式为:在某个时刻t,得到它依赖的动画曲线A的一个数值,也就是一个世界空间里的矩阵数组,从中选出表示手部的那根骨骼的矩阵,用一个固定的偏移矩阵乘上它,得出的矩阵就是曲线E在t时刻的采样值了)
而当这棵树变的越来越复杂的时候,我们在更新动画时,仍然只需要更新B和C两根曲线,而不必关心有什么其它动画在依赖它们.当然在具体实现过程中,可能需要为每根动画曲线维护一个cache,以避免对同一时刻的数据反复采样.这会带来一些内存开销,但是相对于它的组件化优势来说,我觉得还是值得的.
实现中还要注意:随着时间的推进,描述一条曲线需要的数据会越来越多,这显然不能接受.通常我们只保留当前时刻附近的一小段曲线就行了,比如上面说的路径动画曲线
B,我们可以只保留当前逻辑帧和上一个逻辑帧两个路点数据就行了,当然也可以根据需要多保留一些,比如你需要得到曲线的变化情况(导数,二阶导数),或者搞一些时光倒流之类的效果.
最后,使用曲线表示动画的机制可以使逻辑帧与显示帧彻底的分开,从而使用固定的频率来刷新逻辑帧,这样可以确保游戏逻辑不会因为电脑速度不同而不同(固定频率的逻辑帧刷新可以在物理计算,游戏录像,或者类似starcraft的网络同步机制中发挥关键的作用).而在显示帧里,我们可以只对逻辑数据做读取操作,不作任何的修改.