使用boost::any实现的“泛用”消息体

C/C++中传统的消息体通常是以C struct实现的。在这种消息体中,第一个int型的字段标识了该消息的类型(通常是枚举值),第二个int型的字段标识了该消息体的长度,再往下才是消息体本身。这样的消息体效率很高,但有几个缺点。首先是消息体的长度是固定的,第二是消息体内容的格式也是固定的,并且需要在C/C++中定义。

我希望SaberCore是脚本驱动的,如果需要在C++中定义消息体结构,那就实在是太煞风景了。我想到的第一个解决方案是在Lua脚本中定义消息的结构,但这种方法实现起来麻烦用起来也麻烦。第二个解决方案是定义一种“泛用”型消息体,可以胜任任何种类的消息数据。

一提起“泛用”型消息体,第一个想到的就是Android中的intent,intent中可以附带数据extra。它之所以是泛用的,是因为extra不但是变长的,而且你可以向extra中put入任意类型的数据。因为Java所有对象都派生自共同的基类Object,并且所有东西都是new出来的,所以这点其实很容易做到。C/C++中最大的问题在于通常情况下同一个容器中不能放置不同类型的数据。当然我们可以在里面放void *指针,但new出来的东西需要被delete,我们总不能惦记着一个消息体用完还要delete吧?

下面隆重介绍解决方案:boost::any,当当当当!简单直白地说,boost::any可以让C++变成弱类型语言。假如我们定义了这样的一个容器:

std::vector<boost::any> v;

那么你就可以这样放置数据:

v.push_back(1);
v.push_back("我是字符串");
v.push_back(true);
v.push_back(std::vector<int>())

可以这样取出数据:

int a = boost::any_cast<int>(v[0]);
std::string b = boost::any_cast<std::string>(v[1]);
bool c = boost::any_cast<bool>(v[2]);
std::vector<int> d = boost::any_cast<std::vector<int>>(v[3]);

是不是很碉堡

有了这样的一个工具,泛用消息体就很容易定义了。下面是其代码实现:

class scEvent
{
private:
    // 使用字符串而不是枚举型来定义事件类型
    std::string mName; 
    // 使用key - value的方式来索引数据
    std::map<std::string, boost::any> mItems;
public:
    // 各种get/set函数...
};

有兴趣的可以看看boost::any的源码实现,不得不感叹写boost的那些高手怎么可以想到C++还能这么写。

这样的一个泛用型消息体缺点当然大大地有,并且主要是在效率上的。它的体积会比传统的消息体大上几倍,因此拷贝需要消耗更多的时间。同时使用字符串而不是枚举值来区分事件类型,字符串比较也会消耗很多的时间。但便利性和效率向来是难以共存的~也许当性能出现瓶颈时,我会考虑使用传统的消息体。但现在嘛~ 🙂

SaberCore中的多线程与消息机制

经过这么多天艰难的摸索,SaberCore的多线程和消息机制终于初见雏形了。虽然实现得不是很高效,但丑媳妇总要见婆婆,还是在这里分享出来。

SaberCore中的多线程:

SaberCore是完全运行在时间轴上的引擎。我曾在这篇日志中介绍过时间轴动画系统的设计,其实动画时间轴是由普通的时间轴派生而来:

普通时间轴控制着调用频率,引擎中的各组件以回调函数的方式将自己注册进时间轴:

细心的人会发现我之前的截图中,窗口FPS永远都是62帧 : ),这是因为渲染模块所在的时间轴被限制了调用频率。

时间轴分为主轴和次轴,所有主轴运行在主线程上(即使调用频率不一样),所有次轴运行在分支线程上,并且任意两条次轴不会运行在同一个分支线程上。这样,SaberCore是一个天生的多线程引擎,但她也可以很容易地变为单线程的(只需要让所有的时间轴都跑在主线程上)。

SaberCore中的消息系统:

有点类似安卓,如果在其他线程上直接调用渲染线程的函数进行绘制,那肯定会出问题。因此,不同的时间轴之间通过传递消息来进行通信。消息的第一个字段是name,起到了id的作用。所有的消息统一由消息路由器负责分发管理,消息路由会保证任何两个不同类型的消息不会具有相同的name属性。

为了接受和发送消息,我们要做以下几件事:

  1. 创建一个消息队列用以接收消息,每个消息队列有一个独一无二的名字以作标识。
  2. 向消息路由中注册事件,其格式为(事件名称, 消息队列名称)。
  3. 向消息路由中put事件,路由会根据事件的名字将事件递送到指定的消息队列。
  4. 用户周期性地从自己的消息队列中fetch事件。

这是一个邮差模型,一条消息只有一个目的地,不支持广播。消息路由就像是邮差一样,将收集到的信件按照信件上的地址递送到不同的邮箱。其实,就游戏引擎内部而言,一条消息并不需要有多个接收者,所以这样的一个模型是够用的。

下面的图展示消息、消息队列和消息路由之间的关系。虽然画得有点龊,但应该够直白。

由于消息系统是为多线程设计的,因此每个消息队列都拥有自身的锁以防止不好的事情发生。云风的这篇博文中介绍了一种无锁的多线程消息队列,非常具有启发性。但要实现这样一个队列难度很大。首先它是用指针移动的原子操作来避免多线程下的竞争条件,但这个原子操作要怎么实现我还没想明白 – – 。再者就是我为了贪图方便,大量使用了stl中的容器以及boost中的智能指针。而这样的一个队列要想高效地实现,必须要使用原始的指针和自定义的数据结构。所以说,还是算了吧~


分享一下我在编码过程中遇到的一些小问题吧。

在EventRouter中,我将多个EventQueue放入一个std::map中管理,没想到出现了编译错误。我找了半天,终于发现原因出在boost::mutex上。我使用了boost::mutex作为多线程锁,boost::mutex是不可拷贝的,它继承自boost::noncopyable。当我把EventQueue放入std::map中时,会发生拷贝行为,于是就出现了编译错误。解决方法也很简单,就是放指针而不是放实例。

我之前在某本书上看到这么一句话“现代的C++程序中完全可以做到不出现delete”,结果一时头热在引擎中大量使用了boost::shared_ptr,包括消息。我的消息体并不是一个C struct而是一个类,不同类型的消息通过派生这个类来实现。我的消息队列中存放的不是消息体,而是消息的指针。当然,这个指针是经过shared_ptr包装的。
比如说我有一个叫做scTestEvent的消息是派生自scEvent的,所以我可以这样:

shared_ptr<scEvent> evt = shared_ptr<scEvent>(new scTestEvent());
scEventRouter::getSingleton().putEvent(evt);

那么当我将其从消息队列中取出来,并想转回scTestEvent类型,一开始我是这么做的:

shared_ptr<scTestEvent> e = shared_ptr<scTestEvent>(static_cast<scTestEvent>(evt.get()));

你们可以先无视这个蛋疼的语法。这里最大的问题在于,我将scTestEvent的原始指针从一个shared_ptr中取出来后,又拿去构造了另一个shared_ptr。这样,当其中一个指针先被销毁时,它会顺带销毁内部的原始指针。此时另一个智能指针内部的原始指针就变成了野指针。

下面是我随后的做法:

scTestEvent* e = static_cast<scTestEvent*>(evt.get());

这样就不会出问题了。

今天初步完成了OIS输入模块的整合(InputManager)

这两天在SaberCore中整合OIS输入模块,到今天算是初步完成了。目前我的设计中,InputManager会将某种输入事件(鼠标移动,键盘按下之类的)与某个lua脚本中的某个回调函数绑定起来。这样,用户就可以在lua脚本中处理输入事件。当然了,绑定的工作还是需要用户在C++中完成的。在将来我会考虑把绑定的操作也放在脚本中实现。

以下是对应的lua脚本

我在将C++中的枚举类型导出给lua时遇到了一些小问题,那就是luabind官方文档中只提供了导出类中的枚举常量的演示,所以我并不知道如何导出普通的枚举常量。在寻找方法未果后,我小小地偷了下懒,将普通枚举常量当做类枚举常量导出,这个luabind是允许的。所以如果在C++中的枚举变量叫做KC_F,那么到了lua中就变成了KeyEvent.KC_F,因为我将它附在KeyEvent类中导出了。

有了新工具,生产力就是不一样

今天部分完成了在lua脚本中定义场景。还是验证了那句老话,科学技术是第一生产力啊!有了新工具,我瞬间就为Ogre和SaberCore生成了一堆一堆的包装。只要我愿意,我甚至可以用lua把Ogre完全包一遍……不过那毫无意义。

上图中我使用lua脚本在场景中放置了一个摄像机,一个Ogre头以及三个视口。代码截图:

和python一样方便!

使用luabind,告别lua原生C API

我在上一篇日志中提到,由于多线程的原因,弃用python而改用lua。绝大部分的游戏都会在启动的时候从外部读取某种地图文件来加载场景,而不是直接把场景写死在C++中。在不考虑加密性和文件大小的情况下,我决定直接使用lua文件作为地图文件。这样,我可以先在C++中写好函数,封好接口,再由lua来调用这些函数,来完成场景的创建工作。

整个流程大概是这样的:

创建新的场景实例(C++) -> 场景实例调用特定的lua地图文件(C++调用lua) -> lua地图文件中调用C++中的函数完成场景创建(lua调用C++) -> 场景创建完成,开始游戏(C++)

抽象起来,大概有以下几点

  1. C++中有个叫做Scene的类,class Scene。
  2. 类Scene自己有许多写好的场景创建函数,例如createBuilding(),createVehicle()等等。为方便起见我们就用一个createObject()来代指这些函数吧。这些函数都是非静态成员函数,它们要被导出为lua可使用的函数,并被lua所调用。
  3. 类Scene中有个非静态成员方法叫做loadMap(),该方法负责调用lua脚本。
  4. lua脚本中在通过调用Scene类中的成员函数来创建场景。

如果要使用lua原生的API来完成这些事情,那简直是复杂得难以想象。不过幸好,我们有luabind,它的使用方式与boost::python十分相似,十分方便。这里是luabind的使用文档,十分详细。如果不想看英文的话,这里有中文版的。

为了演示luabind的用法,我首先在C++中写一个名为Scene的类

class Scene
{
public:
	Scene()
	{}

	void createObject(int obj);

	void printObjects();

	void loadMap(std::string fileName);

private:
	std::vector<int> mObjects;
};

在这里,我用一个int型的数组来模拟场景中的物体,createObject()方法就是向数组中添加数字,printObjects()会把数组中的东西一起过打印出来。

void Scene::createObject( int obj )
{
	mObjects.push_back(obj);
}

void Scene::printObjects()
{
	for (auto iter = mObjects.begin(); iter != mObjects.end(); ++iter)
		std::cout << (*iter) << std::endl;
}

在loadMap()方法中,我们打开一个lua虚拟机,将Scene中的成员函数导出,并调用lua脚本中的createScene()函数来创建场景。

void Scene::loadMap( std::string fileName )
{
	try
	{
		using namespace luabind;

		lua_State* L = lua_open();
		luaL_openlibs(L);
		luabind::open(L);
		// 导出类Scene
		module(L)
			[
				// 导出的类名字不必与C++中的一样
				// 方法也是这样
				// 但是为了看着方便
				// 我让它们名称都一样了
				class_<Scene>("Scene")	
				.def(luabind::constructor<>())	
				.def("createObject", &Scene::createObject)	
				.def("printObject", &Scene::printObjects)	
				// 注意到我并没有导出loadMap()方法
			];
		// 加载lua文件
		luaL_dofile(L, fileName.c_str());
		// 调用lua文件中的createScene方法
		luabind::call_function<void>(L, "createScene", this);
	}
	catch (luabind::error& e)
	{
		std::cout << e.what() << std::endl;
	}
}

在lua中的脚本是这样的:

-- map.lua
function createScene( scene )
	for i=0, 10 do
		scene:createObject(i)
	end
end

现在,我在main函数中调用Scene的loadMap函数:

void main()
{
	Scene s;
	s.loadMap("test.lua");
	s.printObjects();
	system("pause");
}

大功告成!

游戏引擎中多线程和网络的一些实践,近日一些小总结

自己整个游戏引擎真心不容易,即使是最简陋的,也需要花费很多的心机。我感觉我的思维就在不断地跳来跳去,今天还在考虑时间轴动画,第二天就发现有些地方需要和多线程装载一起考虑,第三天又跳去考虑网络。果然我的经验还是太浅了,感觉就是把大学没有掌握的知识都过了一遍。

一开始我准备使用python来作为游戏引擎的脚本,主要原因是boost::python已经很好地封装了C++和python的接口,使用起来非常方便。然而在多线程中这就出现问题了。因为Python的虚拟机使用GIL,这个玩意的存在使得Python中基本不可能存在真正的多线程,无法发挥多核的优势。不得已之下我只能转投Lua。一开始使用Lua我尝试这自己封装Lua和C++交互的接口,我希望可以在C++中调用Lua,然后又从Lua中调用回C++某个类的非静态成员函数。我发现要完成这样的工作,封装实在是太蛋疼了,我一度徘徊在崩溃的边缘……幸好我在网上找到了luabind,它使用了与boost::python类似的方法来封装C++和Lua的接口。谢天谢地!生活瞬间变得简单很多。

另外就是网络。我希望我的游戏引擎可以支持小型的P2P局域网对战,也就是类似CS和魔兽的那种。我遇到的第一个问题就是这样的一个引擎如何实现单机游戏。我的想法是,把单机看作是多人游戏的一个特殊版本(受GEA启发)。一开始尝试让服务器和客户端同时运行在一个进程的两个不同线程中,之间使用socket进行通讯。事实证明这样做……是不行的。程序运行起来会出“端口已经被打开”的错误。看来socket只能提供两个进程之间的通信,而在一个进程的两个线程中,由于使用的是同一个socket文件描述符,因此无法打开两次。

但毫无疑问魔兽争霸是一个单进程单线程的游戏。因为即使在游戏最卡的时候,魔兽CPU占用率都不会超过50%(我的是双核)。并且,在开启游戏时,我只能看见一个魔兽进程。我猜测CS就更是如此了,毕竟在那个年代,多核CPU是闻所未闻的概念。突然我觉得我陷入了思维的死路,在单机游戏时(或者说是作为多人游戏的主机时),服务端和客户端可以在两条不同的线程上,但它们完全没必要使用socket通信啊……

网络游戏的流程大概是这样的:1.服务端计算->2.服务端将计算结果包装为游戏事件(或之类的东西)->3.服务端将事件封成数据包,并将数据包发给客户端->4.客户端接包,解封成游戏事件->5.客户端处理游戏事件。如果是本机连本机的情况,步骤3和4是可以去除的,服务端和客户端之间可以直接使用一个双缓冲队列来完成数据交换。应该只需要一个if..else语句,就可以很好地区分是本机还是网络,如果是网络的话还是加入步骤3和4。


昨天是第一天去公司上班实习。不知道为什么,上班真的很累,晚上回到宿舍什么都不想干了。尽管由于我是第一天,一整天基本上是无所事事。我猜可能是因为换了陌生的环境以后人变得比较紧张,精神压力也大的原因吧。大概过几天就可以适应了。公司的环境还不错,饭堂伙食也很好。而且我环顾四周,发现貌似我们组的显示器比其他组的都要大一圈。CPU是i7四核3.5GHz;内存8G;显示器不知道是二十几寸,但我两个笔记本拼在一起应该都没它大。我猜开发组由于经常编译的原因,机器要好一点才行吧。

但……唯一让我蛋疼的是……我们组居然是在……用Java……噢……

一个使用关键帧与属性结合的动画系统的实现

在上次的日志中说到,准备放弃从渲染引擎开始写一个游戏引擎。目前希望使用Ogre作为图像引擎,再和其它乱七八糟的组件一起“拼装”一个引擎出来。前一段时间在做实训的时候(使用了Ogre),对Ogre的动画模块有些失望。我希望用户可以非常方便地创建各种动画,因此我萌生了实现一个“通用”动画系统的想法。

受多方面的启发,我希望我的动画系统有着以下的一些特性。

  1. 动画挂载在时间轴上,通过对时间轴进行缩放,可以调节动画的速度。这是受到了GEA的启发。
  2. 我希望像WPF那样把动画做成属性动画,也就是通过改变对象的属性来完成动画。
  3. 我希望像Flash那样提供关键帧,关键帧之间的动画可以使用不同的插值方程(线性、二次等)。
  4. 我希望可以用XML来表示动画,就像WPF那样。

在两天的努力,以及亮哥大神的帮助下,我终于基本完成了!下面是对我的动画系统的小小演示。

我的测试环境用的是Ogre的基础项目工程,原本的基础工程场景创建的代码和效果图如下。

void scAnimationTest::createScene(void)
{
    Ogre::Entity* ogreHead = mSceneMgr->createEntity("Head", "ogrehead.mesh");

    Ogre::SceneNode* headNode = mSceneMgr->getRootSceneNode()->createChildSceneNode();
    headNode->attachObject(ogreHead);

    // Set ambient light
    mSceneMgr->setAmbientLight(Ogre::ColourValue(0.5, 0.5, 0.5));

    // Create a light
    Ogre::Light* l = mSceneMgr->createLight("MainLight");
    l->setPosition(20,80,50);
}

现在,我为这个Ogre头增加两个动画,一个令他前后移动,一个令他自转。因此这个头现在会在前后移动的同时自转。

为了完成这个效果,我增加了几行代码。现在createScene()函数看起来是这样的:

void scAnimationTest::createScene(void)
{
    Ogre::Entity* ogreHead = mSceneMgr->createEntity("Head", "ogrehead.mesh");

    Ogre::SceneNode* headNode = mSceneMgr->getRootSceneNode()->createChildSceneNode();
    headNode->attachObject(ogreHead);

    // Set ambient light
    mSceneMgr->setAmbientLight(Ogre::ColourValue(0.5, 0.5, 0.5));

    // Create a light
    Ogre::Light* l = mSceneMgr->createLight("MainLight");
    l->setPosition(20,80,50);

	// 创建时间轴
	scTimeLine* tl = mTimeLineManager->createTimeLine("me", 60);
	// 创建动画
	scVector3Animation* ani = tl->createVector3Animation([headNode](const Ogre::Vector3& position){headNode->setPosition(position);}, true);
	// 创建关键帧
	ani->createKeyFrame(2000, Ogre::Vector3(0, 0, -500));
	ani->createKeyFrame(4000, Ogre::Vector3(0, 0, 0));

	// 创建时间轴
	tl = mTimeLineManager->createTimeLine("me2", 5);
	// 创建动画
	scF32Animation* ani2 = tl->createF32Animation([headNode](f32 rad){headNode->yaw(Ogre::Radian(rad));}, true);
	// 创建关键帧
	ani2->createKeyFrame(0, 0.5f);
}

是不是非常简洁!实际上我只做了三件事:

  1. 创建一条时间轴。在上面我创建了两条时间轴,那是因为我希望两个动画可以分别以不同的频率被调用(60Hz和5Hz)。
  2. 创建特定类型的属性动画。为了使Ogre头前后移动,我创建了一个Vector3型的属性动画。为了使Ogre头旋转,我创建了一个F32(float)型的动画。
  3. 为动画添加关键帧。

动画一旦创建出来后,就由时间轴管理。时间轴又由时间轴管理类管理。动画可以被设置为循环播放(上面的两个动画都被我设置为循环播放)和播放n次(n为大于0的整数)。我还可以通过调整时间轴的缩放来控制动画的速度。事实上,我只要每按一次U键,Ogre头前后移动的动画速度就会加快一倍,而自转速度则不会增加,因为它们处于不同的时间轴上。

由于动画的创建非常简单,因此可以非常容易地使用XML文件描述它,然后再让引擎加载XML动画文件来创建动画。只不过这一步我还没有做~

下面就讲讲我的思路吧

时间轴

首先是时间轴。GEA一书中提到了时间轴的概念。时间轴的好处在于把游戏的逻辑时间线和系统的硬件时间隔离了开来,我们甚至可以通过操控时间轴的缩放来调节游戏运行的速度。(玩过SC或红警这一类RTS游戏的同学都有游戏速度这个概念吧?)

我的时间轴模型是这样的:

有一个管理类管理着所有的时间轴,这个管理类为单例。我们看到名为Physics的时间轴的调用频率(30Hz)比名为Render的时间轴(60Hz)低,因此它得到调用的机会将更少。设置调用频率的好处在于,我们可以将某些实时性不高的时间轴的调用频率设低(例如我可以将AI时间轴的频率设成2Hz),而将宝贵的CPU时间让位于实时性高的时间轴(如渲染时间轴,我们将它设为60Hz)。

时间轴有优先级。优先级高的时间轴将被优先调用。如果多条时间轴存在依赖关系,这确保了它们可以按顺序被调用。

每个时间轴在被调用时,会从管理类处查询到上一帧到这一阵持续的时间(如果是60Hz,那么这个时间大概是16毫秒)。时间轴然后会把这个时间片传给挂载在该时间轴上的动画。要完成时间缩放其实非常简单,只需要将时间片乘以一个缩放因子就可以了。例如,我先将时间片乘以2,再传给下面的动画。那么对于挂载在这条时间轴上的动画来说,时间的流逝速度就加倍了。

时间轴上面可以挂载动画,同一个时间轴显然可以挂载许多动画。动画一定是在调用点被调用,但动画的结束位置不一定是调用点。

属性动画

属性动画就是通过不断改变目标物体的属性,以达成的动画效果。比如有一个矩形,我每帧将其width属性增加,那么这个矩形就会不断变宽。WPF中大量使用了这种属性动画。

属性动画的一大优点是,只要我对某种类型的属性定义了一个动画,那么所有拥有该类型属性的对象都可以运用这个动画。例如,我创建了一个名为scVector3Animation的属性动画,它作用于类型为Ogre::Vector3的属性。那么我就可以用这个动画去改变模型的位置、模型的缩放、模型的朝向、摄像机的位置等等一切拥有类型为Ogre::Vector3属性的物体。

为了要完成属性动画,我需要将以下三个事物绑定在一起

  1. 特定类型的属性动画,如scVector3Animation;
  2. 需要被动画作用的对象,如SceneNode* node;
  3. 需要被动画所改变的属性,如node的position属性。

为了完成这个任务,我需要用到C++中的lambda表达式。下面我将列出scVector3Animation类简化版本的代码,但足以用来说明问题。

#include <functional>
class scVector3Animation
{
public:
    typedef std::function<void (const Ogre::Vector3&)> Vector3Setter;
    explict scVector3Animation(Vector3Setter const& setter)
        : mSetter(setter)
    { } 

    void run(u32 time)
    { mSetter(Ogre::Vector3(0, 0, 0)); }

private:
    Vector3Setter mSetter;
}

scVector3Animation中有一个类型为Vector3Setter的成员变量,这其实是一个仿函数(函数对象)。它对应的函数签名是void (const Ogre::Vector3&),这正是SceneNode::setPosition()函数(以及其他类似的setter函数)的签名。

这个仿函数会在构造函数中传入,并作为成员变量保存。在run函数中,它将被调用。在上面这个例子中,run函数只是不断地给setter传入0, 0, 0参数,看起来毫无意义。但在真实情况下,被传入的参数将会是两个关键帧之间差值的结果。

然后,我们要这么做

void main()
{
    SceneNode* node;
    scVector3Animation ani( [node](const Ogre::Vector& pos){node->setPosition(pos);} );
}

我在这里使用了lambda表达式。这个lambda表达式创建了一个匿名的仿函数。这个仿函数对应的函数签名为void (const Ogre::Vector3&),正好与我们设定的Setter符合。这个仿函数在创建的时候会从外部捕获node对象,并将其作为自己的成员。在它的“函数体”中,这个仿函数将会调用node对象的setPosition()函数,将外部传入的参数设置为node的position属性。

于是,只要我们这样

ani->run(1234);

参见上上面的代码块,run函数会调用仿函数(mSetter)的函数体,仿函数(mSetter)又会调用node对象的setPosition()函数。在上面的例子中,它把node的位置设为了0, 0, 0。至此,我们已经实现了对象、属性和动画三者的绑定。

关键帧动画

我们使用关键帧来控制动画的进程,任意两帧的关键帧之间的动画通过插值来完成。像Ogre::Vector3,float这样的类型都是可插值的。然而,有些类型,如bool、string,都是不可插值的。事实上,对于不可插值的动画,我们只需要使用前一关键帧的值就可以了。

使用关键帧的一个好处在于,我们只需要定义关键帧就行了,中间的过程都交给插值去做,因此非常适合使用XML来定义。

另一个好处在于,对于任意两帧之间的插值,我们可以任意选择插值算法。一般来说,我们只要用到5个参数,分别是

  1. 初始时间 t0
  2. 结束时间 t1
  3. 初始值 val0
  4. 结束值 val1
  5. 当前时间 t

使用这些参数作为接口,我们甚至可以让前两帧做线性插值,后两帧做二次插值,非常的方便。


由于只是对属性(数值)进行动画,所以与对象类型无关。这样的一套动画系统,既可以用在Ogre上,也可以用在其它的组件如MyGUI上。同时,时间轴不仅仅用来处理动画,还可以用来处理逻辑。

接下来我会修整修整,让实现变得更优雅一些。

camera 3 (外一篇随笔)

我之前的设想是camera自身不带任何位置属性,完全通过父节点来赋予camera的位置和朝向信息。这样做的一个很麻烦的地方在于,操纵节点的方法和操纵camera的方法有些许不同。当我们想改变节点的朝向时,我们会调用节点的rotate方法;当我们想要改变camera的朝向时,实际上更常用的是通过调用SetLookAt()函数或者SetLookTo()函数来改变摄像机看向的地点。

悲剧在于,SceneNode缺少一个类似SetLookAt()或者SetUpDirection()这样的函数。如果是在Irrlicht里面,一切都不成问题。因为在Irrlicht中我们可以派生出CameraSceneNode,然后直接为CameraSceneNode增加相应的方法即可。但在组合的模式下,我能想到的方法无非就以下两种。

一是给SceneNode增加SetLookAt()方法。这样做的话,对于不是camera的东西,我们也可以SetLookAt…

二是只给Camera增加SetLookAt()方法,当调用Camera的SetlookAt方法时,改变其父节点的朝向。这样做的麻烦之处在于,如果camera的父节点还挂载了其他物件,那么所有人都会一起改变朝向。

Ogre是这样解决这个问题的。camera拥有自己的position,lookAt,和upDirection。camera仅从父节点继承position属性,所以camera的真正位置是自己的position再加上父节点的position。对于朝向,camera和它的父节点是分离的。目前我采用的就是这种方法,这样做灵活性也提高了。例如,对于FPS游戏,我们可以直接将人物模型Entity和Camera都挂载在同一个SceneNode上,且将Camera的自身位置调整到模型的头部,非常方便。

顺便一提,虽然Ogre中的摄像机坐标系和节点坐标系相对分离,但所有的SceneNode依旧有一个SetLookAt方法……我猜Ogre应该是为了方便吧。


随笔

从上周起我们开始了坑爹的实训。朝九晚五,严格考勤。如果不是周六实验室不开门,坑爹的实训老师希望我们周六也继续实训。简直就是坑爹啊,让我等从来没有在11点之前起过床的人如何适应!

实训内容是用Ogre随便弄个3d游戏出来。我本来是抱着打打酱油的心态,谁知身不由己,变成了组里的负责人之一。除了要想这想那,还要每隔10分钟被人打断一次问各种问题,弄得时间全无 T_T。一天下来又累的要命,基本上回到宿舍已经不想看电脑。这几天除了翻译翻译文章,自己的项目一点没碰。今天早上起来打开工程,发现脑袋一片空白,完全不记得之前自己写过些啥了 T_T……日啊……

老师说使用SVN作为版本控制器,但是半天没搭起来。我等不及了就自己弄了个Git,结果组里除了我之外没有人用过Git……后来好不容易弄懂了Git的基础用法,老师的SVN终于搭起来了,一堆人眼巴巴的看着我说换回SVN……T_T

最初的时候为了快速开发,我提议使用Ogre的Python版本。后来有组员说Python学的蛋都碎了,还有组员抱怨说Python函数不支持重载。我思量着使用Python来开发大一点的面向对象工程确实比较吃力,就辛辛苦苦把C++版本的Ogre配好了说大家用C++吧。过了一天我去看看那个抱怨python不能重载的组员电脑前看看他进度如何,第一个画面就亮瞎了我的狗眼……一个本身做着create事情的函数叫做get也就算了,居然整整齐齐的有8个重载版本!从两个参数到五个参数不等。好家伙,怪不得抱怨python不能重载。我苦口婆心的说,你看,你这八个函数做的事情其实是一样的,你又不是类库作者,你写的函数还是给自己用的,你何苦花一个上午来写这么多重载……他却很认真地跟我说,可能会用到,可能会用到……当然啦,他之前是写Java的,这我一点都不感到奇怪。搞得我专门去问了另外一个写Java的人,你们Java程序员写程序是不是都习惯由Get/Set函数开始写起……

不过令我欣慰的是,第二天我再看到他的代码时,那八个重载已经只剩下四个了,显然他发现自己根本用不着这么多。我相信按照这个速率,再过两天他就会把剩下的三个也删了。

由于entity和camera性质对立导致的封装性问题

昨天写了一天java终于把工作做完,从图书馆回到宿舍累的倒头就睡,从晚上八点睡到第二天早上10点。一想到再也不用写java真是神清气爽,爬起来继续写Camera类。Camera可以挂载在场景节点上,因此需要通过父节点的位置信息来更新自己的状态。毫无问题!我记得我的Movable类是拥有一个成员叫做mParentNode的。可是当我翻过去看的时候,却惊讶的发现这个成员已经被我自己注释掉了!

怎么回事呢?在我原来的设计中一个Movable只能属于一个SceneNode,但后来当我写到Entity的时候,我又希望一个Entity可以被多个SceneNode重用。因为SceneNode仅仅起到了一个计算位置的作用,所以只要在加入渲染列表的时候将自己的位置传给自己拥有的MovableObject就可以了。

Entity通过传入的节点来更新自身的位置信息,同时为了重用,Entity要将自身Renderable的部分复制一份。由于Entity同时派生自Movable和Renderable,因此这里会发生截断,不过这个截断正是我所希望发生的。

这样,一个Entity被创建出来以后,就可以同时被不同的SceneNode所有,因此在Entity上保存一个父节点指针是没意义的。然而悲剧的是,同样派生自Movable的Camera却需要一个这样的父节点指针,而且Camera并不会在同一时刻被挂到不同的场景节点上。同样可以预见到的另一个悲剧是Light,它和Camera有类似的性质。

遇到这种情况第一时间的反应当然是去看看Ogre是怎么解决这个问题的啦。嘿嘿,Ogre的方式真是简单粗暴:Ogre中,一个Entity不能被多个SceneNode重用。嘛~和我最初的想法蛮一致的嘛。那么如果玩家有一门速射机炮,每秒钟发射60发炮弹,你说该怎么办?Ogre说,嗯,这种情况下你应该调用第一个炮弹Entity的Clone()函数,来复制出剩下的59个。噢噢!真是简单的方法,我怎么没有想到?然而为什么我总感觉有些不对?擦!每个Entity都需要有一个独一无二的名字,因此我需要手工为每个复制出来的炮弹命名?!Ogre说,你个蠢货,Ogre不是会为没有人工指定名字的Entity自动生成一个独一无二的名字吗?我:T_T

于是我决定坚持我自己的方法,这回不听Ogre的!为什么呢,想想看,为了一颗小小的炮弹,居然每秒要生成60个字符串,真是蛋疼……好吧,其实是我实在不想写那个自动生成名字的类。那么我要怎么做呢?完全恢复Movable类里面的mParentNode是不可以的,因为将来可能会有人尝试使用Entity来获取自身的“父节点”。所以我决定部分恢复。

首先,将mParentNode变成protected成员(在saber core中我很少使用protected,极有可能这个会是第一个。即使是在继承关系中,子类也是通过inline的get/set函数来访问父类成员)。在Movable中,仅声明mParentNode的set方法。当Movable被Attach到某个SceneNode上面时,这个SceneNode会调用set方法把自己设为该Movable的父节点。然后在需要使用父节点的子类中,比如Camera或者是Light,我才声明get方法。由于Entity并没有get方法,所以就无法获取Entity的“父节点”了~

说实话这样做让我感觉很不舒服,不过先就这样吧……

camera和viewport 2

让我们来重温一下构成projection matrix的几个属性:FOV(file of view),aspect ratio(宽高比),z near(近平面), z far(远平面)。我们发现这几个属性在绝大部分情况下,是从出生到结束都不会发生变化的(当然有时还是会变的,比如窗口分辨率改变,比如模拟镜头变焦效果)。

反观构成view matrix的几个属性:camera position,look at position, up direction。显然这几个属性会由于摄像机的不断运动而实时改变。

基于以上事实,在一开始我想令Viewport类拥有一个叫做GetProjMatrix()的方法,令Camera类拥有一个叫做GetViewMatrix()的方法,并将FOV,zNear和zFar属性放入Viewport中。这样,Viewport就可以为projection matrix建立缓存,并当自身成员发生变化的时候通知缓存更新。与此相对的是,Camera的view matrix不需要建立缓存。

这种设计最大的优点就是非常简单且耦合度低,然而最大的问题是,camera也需要FOV,zNear和zFar。因为这三个属性实际上构成了camera的视域体(view volume)或者说是视平截头体(view frustum),而SceneManager在遍历场景并决定要将哪些物体送入渲染管线时,需要通过这个范围来进行判定,在视域体之外的物体明显是不需要被渲染的。因此我们应该将FOV,zNear和zFar归入Camera类。

这样会导致一个问题,那就是当camera的属性发生变更了以后,需要通知viewport更新缓存。最初的想法是,将Viewport的某个Notify()函数注册进Camera中。但我觉得这样实在是麻烦,还不如直接在Camera中存一个Viewport的指针。同时按照原来的从属关系,Viewport中本身就存有Camera的指针,因此他们的关系现在变成了这样:

UML使用这种不带箭头的线表示两个类之间的“你中有我我中有你”的蛋疼耦合关系。为了稍稍降低一下复杂度,我规定只有Viewport拥有SetCamera()方法。为了再简化一下问题,我规定Camera和Viewport是一一对应的,也就是说一个Camera仅属于一个Viewport。

当Camera中某个影响projection matrix的属性(比如FOV)发生改变时,将会调用Viewport的Notify()函数,它看起来会是这样:

void scCamera::SetFOV(float fov)
{
    this.mFov = fov;
    if (this.mViewport)
        this.mViewport->NotifyUpdate();
}

在Viewport中我们可以使用懒汉的方式更新缓存,也就是在调用GetProjMatrix()的时候判断是否需要更新,它看起来会是这样:

void scViewport::NotifyUpdate()
{
    this.mNeedUpdate = true;
}

const XMFLOAT4x4& scViewport::GetProjMatrix()
{
    if (mNeedUpdate)
    {
        // update projection matrix
    }
    return mProjMat;
}