六、游戏实体
所有的游戏都是由不同的对象和这些对象的行为方式组成的。吃豆人拥有吃豆人本人、幽灵、吃豆点、能量球和墙壁等物品。这些对象中的每一个都有不同的行为。吃豆人对玩家的输入做出反应,通过吃能量球可以从猎物变成捕食者。这改变了鬼魂的行为,它们被置于一种逃离玩家的状态。
我们的游戏需要一组更简单的行为,但我们将使用最先进的方法来构造对象,以感受现代游戏是如何构造游戏对象的。传统上,游戏对象是用一个普通的类层次结构构建的,从一个基类开始,在每一层中添加专门化,直到我们有了一个在游戏中可用的类。这种方法的问题是不灵活。一旦游戏达到合理的复杂程度,添加新的类型会变得特别困难。还可能存在与菱形继承相关的问题,特定的对象不能很好地适应层次结构,导致理论上应该很简单的对象构造过于复杂。
今天,现代游戏架构更有可能使用基于组件的系统来构建。在这一章中,我们将看看如何使用这样的系统来构造对象。
我们还将看到一个事件系统,它将允许我们告诉游戏对象关于游戏事件的信息,然后他们可以对这些事件做出反应。这又是一个非常有用的现代系统,允许对象选择他们感兴趣的事件。以前,生成事件的系统负责通知它认为可能需要做出反应的每个对象。
最后,我们将继续实现玩家和 AI 对象,它们将在我们的游戏中使用。
什么是游戏实体?
游戏实体是一个非常简单的概念。游戏世界中存在的任何物体都是一个实体。从汽车到人,爆炸桶,电源和射弹,如果对象是一个需要在游戏世界中建模以对游戏产生影响的对象,它就是一个实体。
对于初学游戏的程序员来说,有些实体可能不太清楚。这些是世界上必须存在的物体,但不一定是可见的。灯、照相机、触发盒和声音发射器都可以是属于这一类别的游戏对象的例子。图 6-1 显示了车辆的传统等级体系。
图 6-1 。车辆对象层次结构
我们将有一个名为GameObject
的类,而不是使用这个类的层次来定义我们将在游戏中使用的对象的类型。复杂对象将通过向该对象添加组件来构建。我们将在本章的后面看一下我们将需要什么类型的组件;目前,清单 6-1 显示了GameObject
类。
清单 6-1。 游戏对象类
class GameObject
{
template <class T>
friend T* component_cast(GameObject& object);
template <class T>
friend T* component_cast(GameObject* pObject);
private:
typedef std::tr1::unordered_map<unsigned int, Component*> ComponentUnorderedMap;
typedef ComponentUnorderedMap::iterator ComponentUnorderedMapIterator;
ComponentUnorderedMap m_components;
template <class T>
T* GetComponent() { return static_cast<T*>(GetComponent(T::GetId())); }
Component* GetComponent(unsigned int id);
public:
GameObject();
∼GameObject();
template <class T>
bool AddComponent();
};
清单 6-1 包含了我们到目前为止看到的一些最复杂的代码,所以我们将一行一行地浏览。C++ 中的关键字friend
用于允许其他类或函数调用属于该类实例的私有方法。在我们的例子中,我们定义了一个名为component_cast
的方法,它将被用来把一个对象转换成一个指向组件类型的指针。为了方便起见,component_cast
方法 被重载以获取指向GameObject
的指针或引用,这样我们就不需要在整个代码库中取消对指针的引用。这些方法也被模板化,这样我们就可以使用模板语法来指定要将对象转换成哪种类型的组件。
然后我们可以看到一个typedef
表示一个unordered_map
的Component
指针,另一个表示一个 map 类型的迭代器。
模板方法 GetComponent
是从friend component_cast
函数中调用的方法,我们将在清单 6-2 中快速查看。这个方法调用GetComponent
的非模板化版本,并从模板类型传递从static GetId
方法获得的 id。如果通过模板作为类型传递的类不包含static GetId
方法,我们将在编译时得到一个错误。
然后,在声明另一个用于向对象添加组件的模板化方法之前,我们声明我们的构造函数和析构函数。
清单 6-2 显示了重载component_cast
方法的函数定义。
清单 6-2。 组件 _ 铸件
template <class T>
T* component_cast(GameObject& object)
{
return object.GetComponent<T>();
}
template <class T>
T* component_cast(GameObject* pObject)
{
T* pComponent = NULL;
if (pObject)
{
pComponent = pObject->GetComponent<T>();
}
return pComponent;
}
这两种方法的主要区别在于,基于指针的版本在对传递的对象调用GetComponent
之前会检查指针是否不为空。对于熟悉 C++ cast 类型如static_cast
的程序员来说,这种为GameObject
包含的Component
对象实现访问器的方法将会非常熟悉。实际中的代码看起来像清单 6-3 中的所示。
清单 6-3。 组件 _cast 用法举例
ComponentType* pComponent = component_cast<ComponentType>(pOurObject);
当我们在本章后面讨论机器人跑垒员的Component
实现时,我们会更多地关注这一点。
清单 6-4 显示GetComponent
,这是一个简单的方法。
清单 6-4。 GetComponent
Component* GameObject::GetComponent(unsigned int id)
{
ComponentUnorderedMapIterator result = m_components.find(id);
return result == m_components.end()
? NULL
: result->second;
}
我们的map
使用每个Component
的 id 进行键控,所以我们可以通过简单地调用find
并把 id 作为参数传递来从map
中检索一个Component
。
清单 6-5 中的所示的AddComponent
方法 正如它的名字所暗示的那样:它给我们的对象添加了一个组件。
清单 6-5。 AddComponent
template <class T>
bool GameObject::AddComponent()
{
bool added = false;
ComponentUnorderedMapIterator result = m_components.find(T::GetId());
if (result == m_components.end())
{
T* pNewComponent = new T(this);
if (pNewComponent)
{
std::pair<unsigned int, Component*> newComponent(
T::GetId(),
pNewComponent);
std::pair< ComponentUnorderedMapIterator, bool> addedIter =
m_components.insert(newComponent);
added = addedIter.second;
}
}
return added;
}
首先,它调用 m_ components
上的find
来检查我们是否已经将这种类型的Component
添加到对象中。如果Component
在unordered_map
中还不存在,我们创建一个通过模板参数传递的类类型的新实例。对指针的有效性检查将有助于防止我们的代码在没有创建对象的情况下崩溃。new
通常在内存不足的情况下会失败,所以如果失败了,我们很可能会看到很糟糕的事情发生。
为了给unordered_map
添加一个元素,我们需要创建一个std::pair
。我们的pair
在键(在我们的例子中是由T::GetId()
返回的值)和指向新创建组件的指针之间创建一个映射。m_components.insert
以传递的pair
作为参数被调用。insert
返回另一个pair
,由一个iterator
到map
和一个bool
组成。如果新的Component
被成功添加到map
中,bool
的值将为true
。
GameObject
中最后一个注意的方法是析构函数。我们在的清单 6-6 中看看这个。
清单 6-6。 游戏对象:*游戏对象
GameObject::∼GameObject()
{
for (ComponentUnorderedMapIterator iter = m_components.begin();
iter != m_components.end();
++iter)
{
Component* pComponent = iter->second;
if (pComponent)
{
delete pComponent;
pComponent = NULL;
}
}
}
GameObject
的析构函数有一个简单的工作:它遍历m_components unordered_map
并删除每个添加的Component
。
组件的基类是一段简单的代码,如清单 6-7 所示。
清单 6-7。 组件声明
class Component
{
private:
GameObject* m_pOwner;
public:
explicit Component(GameObject* pOwner)
: m_pOwner(pOwner)
{
}
virtual ∼Component() {}
virtual void Initialize() = 0;
GameObject* GetOwner() { return m_pOwner; }
};
如您所见,Component
将只包含一个指向其所有者的指针和一个纯虚拟初始化方法。GetOwner
方法稍后会有用,因为它允许Components
访问它们所属的GameObject
,并在必要时使用component_cast
访问其他的Components
。
既然我们已经有了一个封装我们的对象的类和一个组件的基本接口,我们应该看看将用于与对象通信的事件系统。
通过事件与游戏对象通信
与对象通信的正常过程是调用组成类的方法。这要求我们有一个合适的进程来访问对象,并且我们知道在该对象上调用哪些方法。如果我们需要在多个对象上调用同一个方法,我们会增加代码的复杂性,因为我们需要一个方法来收集我们将要调用的所有对象。随着游戏项目的增长,这些任务变得越来越难以正确管理,需要适当的规划和管理来确保它们不会消耗太多时间。
我们可以通过切换到基于事件的系统来避免所有这些问题。当我们希望对象执行一项任务时,我们不需要调用每个类的方法,而是简单地广播一条事件消息。订阅它们希望被通知的事件是其他对象的责任。真的就这么简单。
事件类
首先,我们来看看封装了一个Event
的类。代码可以在清单 6-8 中看到。
清单 6-8。 事件宣言
typedef unsigned int EventID;
class Event
{
private:
typedef std::vector<EventHandler*> EventHandlerList;
typedef EventHandlerList::iterator EventHandlerListIterator;
EventHandlerList m_listeners;
EventID m_id;
public:
explicit Event(EventID eventId);
∼Event();
void Send();
void SendToHandler(EventHandler& eventHandler);
void AttachListener(EventHandler& eventHandler);
void DetachListener(EventHandler& eventHandler);
EventID GetID() const { return m_id; }
};
一个Event
的类是另一个简单的类。你可以看到我们将在m_listeners
中存储一个EventHandler
指针的列表。我们的Event
也将有一个id
字段,我们将确保它对每个Event
都是唯一的。
公共方法Send
、SendToHandler
、AttachListener
和DetachListener
是执行对象的主要工作的地方。
清单 6-9 展示了Send
方法。
清单 6-9。 事件::发送( )
void Event::Send()
{
for (EventHandlerListIterator iter = m_listeners.begin();
iter != m_listeners.end();
++iter)
{
EventHandler* pEventHandler = *iter;
assert(pEventHandler);
if (pEventHandler)
{
pEventHandler->HandleEvent(this);
}
}
}
这个方法简单地遍历m_listeners
列表的所有成员,从迭代器中检索EventHandler
指针,并在对象上调用EventHandler::HandleEvent
。很简单。清单 6-10 展示了我们如何发送一个Event
给一个单独的对象。
清单 6-10。 事件::SendToHandler( )
void Event::SendToHandler(EventHandler& eventHandler)
{
for (EventHandlerListIterator iter = m_listeners.begin();
iter != m_listeners.end();
++iter)
{
if (&eventHandler == *iter)
{
eventHandler.HandleEvent(this);
}
}
}
在这里您可以看到,我们将只把事件发送给参数中传递的特定对象,并且只有当该对象存在于我们的侦听器列表中时。我们通过AttachListener
和DetachListener
方法管理列表。首先,AttachListener
?? 如图清单 6-11 所示。
清单 6-11。 事件::AttachListener()
void Event::AttachListener(EventHandler& eventHandler)
{
m_listeners.push_back(&eventHandler);
}
将一个对象附加到事件上就像将其地址推送到 m_ listeners
上一样简单。
DetachListener
见清单 6-12 。
***清单 6-12。***Event::detach listener()
void Event::DetachListener(EventHandler& eventHandler)
{
for (EventHandlerListIterator iter = m_listeners.begin();
iter != m_listeners.end();
++iter)
{
if (&eventHandler == *iter)
{
m_listeners.erase(iter);
break;
}
}
}
要删除一个监听器,我们遍历m_listeners
并在迭代器上调用 erase,该迭代器匹配参数中传递的EventHandler
的地址。
您可能已经注意到,这些方法不能防止同一个对象被多次附加,并且只会从列表中删除侦听器的一个实例。为了简单起见,我将确保对象不会被多次添加到调用代码中。
EventHandler 类
现在我们知道了Event
类的样子,我们将看看清单 6-13 中的EventHandler
类。
清单 6-13。 事件处理类 声明
class EventHandler
{
public:
virtual ∼EventHandler() {}
virtual void HandleEvent(Event* pEvent) = 0;
};
这门课实在是再简单不过了。我们提供了一个纯虚拟方法HandleEvent
,让我们的继承类覆盖。
事件管理器
EventManager
如清单 6-14 所示。EventManager 是游戏代码和事件系统之间的接口。
***清单 6-14。***event manager 类声明
class EventManager
: public Singleton<EventManager>
{
friend void SendEvent(EventID eventId);
friend void SendEventToHandler(EventID eventId, EventHandler& eventHandler);
friend bool RegisterEvent(EventID eventId);
friend void AttachEvent(EventID eventId, EventHandler& eventHandler);
friend void DetachEvent(EventID eventId, EventHandler& eventHandler);
private:
typedef std::tr1::unordered_map<EventID, Event*> EventMap;
typedef EventMap::iterator EventMapIterator;
EventMap m_eventMap;
void SendEvent(EventID eventId);
void SendEventToHandler(EventID eventId, EventHandler& eventHandler);
bool RegisterEvent(EventID eventId);
void AttachEvent(EventID eventId, EventHandler& eventHandler);
void DetachEvent(EventID eventId, EventHandler& eventHandler);
public:
EventManager();
∼EventManager();
};
从清单 6-14 中收集到的第一条重要信息是,我们已经把这个类变成了一个单例对象。Singleton 是一个有点争议的设计模式,它允许我们从代码库中的任何一点访问一个类的单个实例。这两个属性对我们的EventManager
实现都很重要。关于如何实现 Singleton 的更多细节,请查看本书的附录。
事件管理器的朋友函数
你可以从清单 6-14 中的类中看到,我们再次利用了friend
关键字来降低该类调用者代码的复杂性。我们来看看清单 6-15 中的朋友函数,看看为什么。
清单 6-15。 EventManager 的好友
inline void SendEvent(EventID eventId)
{
EventManager* pEventManager = EventManager::GetSingletonPtr();
assert(pEventManager);
if (pEventManager)
{
pEventManager->SendEvent(eventId);
}
}
inline void SendEventToHandler(EventID eventId, EventHandler& eventHandler)
{
EventManager* pEventManager = EventManager::GetSingletonPtr();
assert(pEventManager);
if (pEventManager)
{
pEventManager->SendEventToHandler(eventId, eventHandler);
}
}
inline bool RegisterEvent(EventID eventId)
{
EventManager* pEventManager = EventManager::GetSingletonPtr();
assert(pEventManager);
if (pEventManager)
{
pEventManager->RegisterEvent(eventId);
}
}
inline void AttachEvent(EventID eventId, EventHandler& eventHandler)
{
EventManager* pEventManager = EventManager::GetSingletonPtr();
assert(pEventManager);
if (pEventManager)
{
pEventManager->AttachEvent(eventId, eventHandler);
}
}
inline void DetachEvent(EventID eventId, EventHandler& eventHandler)
{
EventManager* pEventManager = EventManager::GetSingletonPtr();
assert(pEventManager);
if (pEventManager)
{
pEventManager->DetachEvent(eventId, eventHandler);
}
}
每个友元函数包装了检索对象的Singleton
指针的代码,验证它已经被创建,然后调用同名的EventManager
类方法。这将安全调用所需的六行代码减少为调用代码中的一行代码,这将有助于提高代码的可读性和生产率。
大 o 符号〔??〕
如果你再看一下清单 6-14 中的,你会发现我们再次使用了一个unordered_map
来存储我们的Events
。我们上次在GameObject
中使用了一个unordered_map
来存储我们的Components
,但是我们当时没有讨论为什么使用这个结构。可以根据算法预计完成所需的时间来评估算法。计算这个时间的方法叫做大 O 记数法。大 O 记数法本身并不测量时间;相反,它为我们提供了一种方法,用于评估对于给定的一组大小为 n 的元素,完成一个算法需要多长时间。
当我们访问一个unordered_map
时,我们给容器一个哈希值,它将这个值转换成存储我们元素的地址。不管在我们的unordered_map
中有多少个元素,这都需要相同的时间长度,因此被称为在常数时间内执行,或者用大 O 符号表示为 O(1)。如果我们使用了一个list
容器来存储元素,list
中的每个新元素都会增加以线性方式查找任何给定元素的时间。在大 O 符号中,这将是 O( n ),对于一个平衡良好的树容器,我们将看到一个大 O of O(log( n ))。
我选择使用unordered_map
,因为我们将对组件和事件执行的最常见操作是从它们的容器中检索它们。当一个游戏试图尽可能快地执行它的代码时,我们可能会有这样的情况,我们有许多事件和组件附加到任何给定的对象上,这对于我们利用unordered_map
访问的 O(1)属性来实现这个目的是有意义的。
EventManager 的接口方法
清单 6-16 显示了用于调用Event
类的Send
和SendToHandler
方法的代码。
***清单 6-16。***event manager::send event()
void EventManager::SendEvent(EventID eventId)
{
EventMapIterator result = m_eventMap.find(eventId);
if (result != m_eventMap.end())
{
assert(result->second);
if (result->second)
{
result->second->Send();
}
}
}
void EventManager::SendEventToHandler(EventID eventId, EventHandler& eventHandler)
{
EventMapIterator result = m_eventMap.find(eventId);
if (result != m_eventMap.end())
{
assert(result->second);
if (result->second)
{
result->second->SendToHandler(eventHandler);
}
}
}
我们从m_eventMap
容器中检索iterator
,然后在它包含的Event
对象上调用相关方法。
清单 6-17 中的方法展示了我们如何向m_eventMap
容器添加一个新事件。
***清单 6-17。***event manager::RegisterEvent()
bool EventManager::RegisterEvent(EventID eventId)
{
bool added = false;
EventMapIterator result = m_eventMap.find(eventId);
if (result == m_eventMap.end())
{
Event* pNewEvent = new Event(eventId);
if (pNewEvent)
{
std::pair<EventID, Event*> newEvent(eventId, pNewEvent);
std::pair<EventMapIterator, bool> addedIter = m_eventMap.insert(newEvent);
added = addedIter.second;
}
}
assert(added);
return added;
}
正如我们之前对unordered_map
所做的一样,我们创建了一个新元素,用它的key
将它捆绑到一个pair
中,并将pair
插入到unordered_map
中。
清单 6-18 显示了EventManager
的析构函数以及从m_eventMap
中清除元素所需的代码。
清单 6-18. 事件管理员:*事件管理员( )
EventManager::∼EventManager()
{
for (EventMapIterator iter = m_eventMap.begin(); iter != m_eventMap.end(); ++iter)
{
Event* pEvent = iter->second;
if (pEvent)
{
delete pEvent;
iter->second = NULL;
}
}
m_eventMap.clear();
}
对EventManager
最重要的最后两个方法是AttachEvent
和DetachEvent
方法。这些用于确保希望接收特定事件的对象被设置为这样做,如清单 6-19 所示。
清单 6-19。 事件管理器的 AttachEvent 和 DetachEvent
void EventManager::AttachEvent(EventID eventId, EventHandler& eventHandler)
{
EventMapIterator result = m_eventMap.find(eventId);
assert(result != m_eventMap.end());
if (result != m_eventMap.end())
{
assert(result->second);
result->second->AttachListener(eventHandler);
}
}
void EventManager::DetachEvent(EventID eventId, EventHandler& eventHandler)
{
EventMapIterator result = m_eventMap.find(eventId);
assert(result != m_eventMap.end());
if (result != m_eventMap.end())
{
assert(result->second);
result->second->DetachListener(eventHandler);
}
}
这是我们目前对EventManager
类的所有内容。在下一节中,我们将创建一个可以添加到GameObjects
中的Component
,并告诉他们将自己添加到Renderer
中。我们将通过使用一个Event
来实现这一点,当游戏应该告诉它的对象进行渲染时,就会发送这个消息。
渲染对象
正如我们刚刚提到的,在这一节中,我们将通过创建一个RenderableComponent
来实践我们刚刚学习过的类。
TransformComponent 类
在我们渲染一个物体之前,我们需要知道它应该放在游戏世界的什么地方。我们将把这些信息存储在另一个Component
、TransformComponent
中,如清单 6-20 所示。
***清单 6-20。***transform component 类声明
class TransformComponent
: public Component
{
private:
static const unsigned int s_id = 0;
Transform m_transform;
public:
static unsigned int GetId() { return s_id; }
explicit TransformComponent(GameObject* pOwner);
virtual ∼TransformComponent();
virtual void Initialize();
Transform& GetTransform() { return m_transform; }
};
TransformComponent
的构造函数、析构函数和Initialize
方法都是空的,因为它们不需要执行任何任务。TransformComponent
的唯一工作是为我们的游戏对象提供一个Transform
对象。
转换类
转换类的定义如清单 6-21 中的所示。
清单 6-21。 转换类声明
class Transform
{
private:
Matrix3 m_rotation;
Vector3 m_translation;
float m_scale;
Matrix4 m_matrix;
public:
Transform();
virtual ∼Transform();
void Clone(const Transform& transform);
void SetRotation(const Matrix3& rotation);
const Matrix3& GetRotation() const;
void SetTranslation(const Vector3& translation);
const Vector3& GetTranslation() const;
void SetScale(const float scale);
const float GetScale() const;
void ApplyForward(const Vector3& in, Vector3& out) const;
void ApplyInverse(const Vector3& in, Vector3& out) const;
void UpdateMatrix();
const Matrix4& GetMatrix() const;
void GetInverseMatrix(Matrix4& out) const;
void GetInverseTransposeMatrix(Matrix4& out) const;
};
这个Transform
类是从大卫·埃伯利在他的网站www.geometrictools.com/
上提供的实现中派生出来的。如果你需要复习你的数学技能,我在这本书的附录中提供了一个向量和矩阵的快速纲要。
Transform
类的访问器方法是不言自明的,所以让我们看看清单 6-22 中的UpdateMatrix
。
清单 6-22。 变换::更新矩阵( )
void Transform::UpdateMatrix()
{
m_matrix.m_m[0] = m_rotation.m_m[0] * m_scale;
m_matrix.m_m[1] = m_rotation.m_m[1];
m_matrix.m_m[2] = m_rotation.m_m[2];
m_matrix.m_m[3] = 0.0f;
m_matrix.m_m[4] = m_rotation.m_m[3];
m_matrix.m_m[5] = m_rotation.m_m[4] * m_scale;
m_matrix.m_m[6] = m_rotation.m_m[5];
m_matrix.m_m[7] = 0.0f;
m_matrix.m_m[8] = m_rotation.m_m[6];
m_matrix.m_m[9] = m_rotation.m_m[7];
m_matrix.m_m[10] = m_rotation.m_m[8] * m_scale;
m_matrix.m_m[11] = 0.0f;
m_matrix.m_m[12] = m_translation.m_x;
m_matrix.m_m[13] = m_translation.m_y;
m_matrix.m_m[14] = m_translation.m_z;
m_matrix.m_m[15] = 1.0f;
}
UpdateMatrix
顾名思义:它用转换的当前状态更新内部矩阵。3×3 旋转矩阵的每个成员都被复制到 4×4 变换矩阵的正确条目中,并在矩阵的对角线上进行适当的缩放。正如 OpenGL 所期望的那样,翻译值被复制到矩阵的翻译条目中的位置 12、13 和 14。
GetMatrix
简单地返回一个我们在清单 6-22 中看到的内部矩阵的引用,所以我们将继续看方法GetInverseMatrix
,如清单 6-23 所示。
***清单 6-23。***Transform::getinversmatrix()
void Transform::GetInverseMatrix(Matrix4& out) const
{
float invScale = 1.0f / m_scale;
out.m_m[0] = m_rotation.m_m[0] * invScale;
out.m_m[1] = m_rotation.m_m[3];
out.m_m[2] = m_rotation.m_m[6];
out.m_m[3] = 0.0f;
out.m_m[4] = m_rotation.m_m[1];
out.m_m[5] = m_rotation.m_m[4] * invScale;
out.m_m[6] = m_rotation.m_m[7];
out.m_m[7] = 0.0f;
out.m_m[8] = m_rotation.m_m[2];
out.m_m[9] = m_rotation.m_m[5];
out.m_m[10] = m_rotation.m_m[8] * invScale;
out.m_m[11] = 0.0f;
out.m_m[12] = -m_translation.m_x;
out.m_m[13] = -m_translation.m_y;
out.m_m[14] = -m_translation.m_z;
out.m_m[15] = 1.0f;
}
如果你以前学过矩阵背后的数学,你可能已经知道逆矩阵是用来逆转原矩阵效果的矩阵。就简单代数而言 1 × 10 = 10。乘以 10 的倒数就是乘以 1/10,所以 10 × (1/10) = 1。逆矩阵执行相同的工作。计算矩阵的逆矩阵是一个计算量很大的过程,但是在游戏开发中,变换矩阵的逆矩阵计算起来要简单得多。
在这种情况下,我们可以使用一些特殊的属性。由于缩放是简单的乘法,我们可以乘以反倍数,正如你在GetInverseMatrix
的第一行看到的,我们通过用 1 除以m_scale
来计算反比例。
我们可以利用的下一个特殊属性是旋转矩阵的情况。旋转矩阵是一种特殊类型的矩阵,称为正交矩阵。这意味着矩阵中的每一行代表一个单位向量。在我们的例子中,旋转矩阵的每一行都应该是一个单位向量,代表要应用的旋转的 x、y 和 z 轴。正交矩阵的逆就是它的转置,所以我们很幸运,因为我们可以很容易地转置矩阵。查看清单 6-23 ,你可以从我们索引到代表矩阵的数组的方式中看到这一点:注意它们是如何不完全匹配的。例如,在第一行中,我们不是按 0,1,2 的顺序复制,而是取第一列 0,3,6。
最后但同样重要的是翻译组件。平移可以简单地认为是加法运算,因此我们可以通过将平移向量元素的负值相加来计算逆运算。
你可能会同意,这比使用传统方法计算倒数要简单得多。
可渲染组件
现在我们有了在世界中放置物体的方法,让我们看看我们将如何渲染它们(见清单 6-24 )。
***清单 6-24。***RenderableComponent
class RenderableComponent
: public Component
, public EventHandler
{
private:
static const unsigned int s_id = 1;
Renderable m_renderable;
public:
static unsigned int GetId() { return s_id; }
explicit RenderableComponent(GameObject* pOwner);
virtual ∼RenderableComponent();
virtual void Initialize();
Renderable& GetRenderable() { return m_renderable; }
virtual void HandleEvent(Event* pEvent);
};
同样,我们有一个非常简单的组件。唯一的字段是Renderable
,我们可以将它传递给Renderer
。该声明与TransformComponent
声明的主要区别在于RenderableComponent
与Component
一起继承了EventHandler
。这意味着我们需要覆盖HandleEvent
;它的代码在清单 6-25 中列出。
***清单 6-25。***RenderableComponent::handle event()
void RenderableComponent::HandleEvent(Event* pEvent)
{
assert(pEvent);
if (pEvent->GetID() == RENDER_EVENT)
{
TransformComponent* pTransformComponent =
component_cast<TransformComponent>(GetOwner());
if (pTransformComponent)
{
m_renderable.GetTransform().Clone(pTransformComponent->GetTransform());
}
assert(Renderer::GetSingletonPtr());
Renderer::GetSingleton().AddRenderable(&m_renderable);
}
}
事情开始以这种方式第一次走到一起。第一个任务是检查我们被传递的事件是否是RENDER_EVENT
。然后,我们使用之前编写的component_cast
方法将所有者指针转换为TransformComponent
指针。一旦我们有了一个有效的TransformComponent
指针,我们就把它的Transform
克隆到另一个Transform
对象中,我们已经把它添加到了Renderable
类中。
在这个方法中,你可以看到《??》第五章中代码的另一个变化是,我们从Singleton
继承了Renderer
。这允许我们从这个调用中直接将Renderable
对象添加到Renderer
中。
TransformShader 类
能够使用Transform
呈现对象的下一步是创建一个支持矩阵的Shader
。清单 6-26 显示了我们TransformShader
的声明。
***清单 6-26。***transform shader 类声明
class TransformShader
: public Shader
{
private:
Matrix4 m_projection;
GLint m_transformUniformHandle;
GLint m_positionAttributeHandle;
GLint m_colorAttributeHandle;
public:
TransformShader();
virtual ∼TransformShader();
virtual void Link();
virtual void Setup(Renderable& renderable);
};
该着色器将具有统一的变换和顶点位置和颜色属性。我们在课堂上也有一个 4×4 矩阵。这是一项临时措施,将允许我们建立一个预测矩阵。当我们考虑实现游戏摄像机时,我们将在第八章中更详细地讨论投影。
清单 6-27 显示了我们的顶点和片段着色器的 GLSL 代码。
清单 6-27。 TransformShader 的构造函数
TransformShader::TransformShader()
{
m_vertexShaderCode =
"uniform mat4 u_mModel; \n"
"attribute vec4 a_vPosition; \n"
"void main(){ \n"
" gl_Position = u_mModel * a_vPosition; \n"
"} \n";
m_fragmentShaderCode =
"precision highp float; \n"
"uniform vec4 a_vColor; \n"
"void main(){ \n"
" gl_FragColor = a_vColor; \n"
"} \n";
}
在顶点着色器中,我们有一个uniform mat4
矩阵,它将存储从Renderer
传来的变换矩阵。该矩阵用于乘以顶点位置,并将结果存储在gl_Position
中。
对于片段着色器,我们有一个统一的片段颜色。这将允许我们使用着色器来渲染多个对象,并使每个对象具有不同的颜色。
现在我们来看看访问清单 6-28 中的统一和属性位置所需的代码。
***清单 6-28。***transform shader::Link()
void TransformShader::Link()
{
Shader::Link();
m_transformUniformHandle = glGetUniformLocation(m_programId, "u_mModel");
m_positionAttributeHandle = glGetAttribLocation(m_programId, "a_vPosition");
m_colorAttributeHandle = glGetUniformLocation(m_programId, "a_vColor");
float halfAngleRadians = 0.5f * 45.0f * (3.1415926536f / 180.0f);
float m_top = 1.0f * (float)tan(halfAngleRadians);
float m_bottom = -m_top;
float m_right = (1280.0f / 720.0f) * m_top;
float m_left = -m_right;
float m_near = 1.0f;
float m_far = 100.0f;
m_projection.m_m[0] = (2.0f * m_near) / (m_right - m_left);
m_projection.m_m[1] = 0.0f;
m_projection.m_m[2] = 0.0f;
m_projection.m_m[3] = 0.0f;
m_projection.m_m[4] = 0.0f;
m_projection.m_m[5] = (2.0f * m_near) / (m_top - m_bottom);
m_projection.m_m[6] = 0.0f;
m_projection.m_m[7] = 0.0f;
m_projection.m_m[8] = -((m_right + m_left) / (m_right - m_left));
m_projection.m_m[9] = -((m_top + m_bottom) / (m_top - m_bottom));
m_projection.m_m[10] = (m_far + m_near) / (m_far - m_near);
m_projection.m_m[11] = 1.0f;
m_projection.m_m[12] = 0.0f;
m_projection.m_m[13] = 0.0f;
m_projection.m_m[14] = -(2.0f * m_near * m_far) / (m_far - m_near);
m_projection.m_m[15] = 0.0f;
}
在这个清单中,您可以看到我们使用glGetUniformLocation
来获取转换u_mModel
的位置,使用glGetAttribLocation
来获取a_vPosition
和a_vColor
的位置。
然后我们有一段代码,它计算出一个叫做平截头体的形状的边,并使用这个平截头体来构造一个透视投影矩阵。这个主题将在第八章中详细介绍,并将被纳入正式的Camera
课程中。
清单 6-29 中的 TransformShader::Setup
方法展示了我们如何将矩阵提供给着色器。
***清单 6-29。***transform shader::Setup()
void TransformShader::Setup(Renderable& renderable)
{
Geometry* pGeometry = renderable.GetGeometry();
if (pGeometry)
{
Shader::Setup(renderable);
Matrix4 mMVP;
renderable.GetTransform().GetMatrix().Multiply(m_projection, mMVP);
glUniformMatrix4fv(m_transformUniformHandle, 1, false, mMVP.m_m);
glVertexAttribPointer(
m_positionAttributeHandle,
pGeometry->GetNumVertexPositionElements(),
GL_FLOAT,
GL_FALSE,
pGeometry->GetVertexStride(),
pGeometry->GetVertexBuffer());
glEnableVertexAttribArray(m_positionAttributeHandle);
Vector4& color = renderable.GetColor();
glUniform4f(m_colorAttributeHandle, color.m_x, color.m_y, color.m_z, color.m_w);
}
}
我们建立了一个临时矩阵,mMVP
。该矩阵存储可渲染矩阵与投影矩阵相乘的结果。然后我们使用glUniformMatrix4fv
将矩阵传递给 OpenGL。第一个参数是统一变换的位置,第二个参数是要上传的矩阵数量,在我们的例子中是一个。传递的下一个参数是false
,它告诉驱动程序我们不希望它在将矩阵发送到着色器之前转置我们的矩阵。最后,我们将指针传递给矩阵数据。
接下来的几行设置了顶点数据流,就像我们之前处理着色器一样。
该功能的最后一个任务是使用glUniform4f
设置颜色均匀性。我们向该方法传递颜色在着色器中的位置以及颜色的 rgba 值的四个浮点值。
现在我们终于写了几个组件,一个支持变换的着色器,并看看EventHandlers
如何响应事件。我们将在下一节使用这些组件,我们将制作一个基本的播放器GameObject
并在应用中渲染它。
玩家对象
在我们使用玩家对象之前,我们将为这个示例应用创建一个任务,如清单 6-30 所示。
清单 6-30。 第六章任务类申报
class Chapter6Task
: public Framework::Task
{
private:
Framework::Geometry m_geometry;
Framework::TransformShader m_transformShader;
Framework::GameObject m_playerObject;
public:
Chapter6Task(const unsigned int priority);
virtual ∼Chapter6Task();
// From Task
virtual bool Start();
virtual void OnSuspend();
virtual void Update();
virtual void OnResume();
virtual void Stop();
};
我们有一个Geometry
类的实例、一个TransformShader
和一个GameObject
,我们将其命名为m_playerObject
。现在我们来看看Start
方法 ,如清单 6-31 所示。
清单 6-31。 第六章 Task::Start()
bool Chapter6Task::Start()
{
using namespace Framework;
Renderer* pRenderer = Renderer::GetSingletonPtr();
if (pRenderer)
{
pRenderer->AddShader(&m_transformShader);
}
m_geometry.SetVertexBuffer(verts);
m_geometry.SetNumVertices(sizeof(verts) / sizeof(verts[0]));
m_geometry.SetIndexBuffer(indices);
m_geometry.SetNumIndices(sizeof(indices) / sizeof(indices[0]));
m_geometry.SetName("android");
m_geometry.SetNumVertexPositionElements(3);
m_geometry.SetVertexStride(0);
RegisterEvent(UPDATE_EVENT);
RegisterEvent(RENDER_EVENT);
RegisterEvent(JUMP_EVENT);
m_playerObject.AddComponent<MovementComponent>();
MovementComponent* pMovementComponent =
component_cast<MovementComponent>(m_playerObject);
if (pMovementComponent)
{
Framework::AttachEvent(Framework::UPDATE_EVENT, *pMovementComponent);
Framework::AttachEvent(Framework::JUMP_EVENT, *pMovementComponent);
}
m_playerObject.AddComponent<TransformComponent>();
TransformComponent* pTransformComponent =
component_cast<TransformComponent>(m_playerObject);
if (pTransformComponent)
{
Vector3 translation(-10.0f, 0.0f, 50.0f);
Transform& transform = pTransformComponent->GetTransform();
transform.SetTranslation(translation);
}
m_playerObject.AddComponent<RenderableComponent>();
RenderableComponent* pRenderableComponent =
component_cast<RenderableComponent>(m_playerObject);
if (pRenderableComponent)
{
Renderable& renderable = pRenderableComponent->GetRenderable();
renderable.SetGeometry(&m_geometry);
renderable.SetShader(&m_transformShader);
Vector4& color = renderable.GetColor();
color.m_x = 0.0f;
color.m_y = 1.0f;
color.m_z = 0.0f;
color.m_w = 1.0f;
Framework::AttachEvent(Framework::RENDER_EVENT, *pRenderableComponent);
}
return true;
}
最后,清单 6-31 显示了用户可以编写的客户端代码,以使用我们为事件、组件和游戏对象系统创建的框架系统。
我们通过向Renderer
注册我们的TransformShader
然后配置Geometry
对象来开始Start
方法。顶点和索引的数量现在是在运行时通过使用sizeof
操作符将包含数据的数组的大小除以数组中单个元素的大小来计算的。
我们接着记录一些事件。我们注册了UPDATE_EVENT
、RENDER_EVENT
和JUMP_EVENT
。我们已经看到RenderableComponent
将使用RENDER_EVENT
将Renderable
添加到渲染队列,但是我们还没有看到更新和跳转的事件;我们很快就会看到这些。
此时,我们开始向我们的GameObject
添加组件。在添加了一个MovementComponent
之后,我们还添加了一个TransformComponent
和一个RenderableComponent
。添加完RenderableComponent
后,我们使用component_cast
从m_playerObject
中检索它,然后设置Geometry
、Shader
以及我们希望与其Renderable
一起使用的颜色。最后,我们附上RENDER_EVENT
。
在继续查看MovementComponent
之前,我们先来看看Update
方法 ,如清单 6-32 所示。
清单 6-32。 第六章 Task::Update()
void Chapter6Task::Update()
{
Framework::SendEvent(Framework::UPDATE_EVENT);
Framework::SendEvent(Framework::RENDER_EVENT);
}
这个系统的美妙之处现在变得越来越明显。代码在大多数方面都非常简洁。为了更新和呈现所有的GameObjects
,我们只需发送更新和呈现事件,所有附加了这些事件的GameObject
实例将会更新和呈现。这再简单不过了。
我们现在将使用这个系统让玩家与游戏互动。该代码将检测玩家何时触摸屏幕,玩家的角色将在空中跳跃。
让玩家跳跃
我们将从直接进入MovementComponent
的代码开始这一部分,如清单 6-33 所示。
***清单 6-33。***movement component 类声明
class MovementComponent
: public Framework::Component
, public Framework::EventHandler
{
private:
static const unsigned int s_id = 9;
Framework::Vector3 m_acceleration;
Framework::Vector3 m_velocity;
public:
static unsigned int GetId() { return s_id; }
explicit MovementComponent(Framework::GameObject* pObject);
virtual ∼MovementComponent();
virtual void Initialize();
virtual void HandleEvent(Framework::Event* pEvent);
};
你可以看到我们定义了两个Vector3
对象,一个用于加速度,一个用于速度。你也许可以从这些名字中看出,在HandleEvent
方法 中,我们将看一看一些基本的物理学。看看清单 6-34 。
***清单 6-34。***movement component::handle event()
void MovementComponent::HandleEvent(Event* pEvent)
{
if (pEvent->GetID() == JUMP_EVENT)
{
TransformComponent* pTransformComponent =
component_cast<TransformComponent>(GetOwner());
assert(pTransformComponent);
if (pTransformComponent &&
pTransformComponent->GetTransform().GetTranslation().m_y < FLT_EPSILON)
{
static const float JUMP_ACCELERATION = 80.0f;
m_acceleration.m_y = JUMP_ACCELERATION;
}
}
else if (pEvent->GetID() == UPDATE_EVENT)
{
TransformComponent* pTransformComponent =
component_cast<TransformComponent>(GetOwner());
assert(pTransformComponent);
if (pTransformComponent)
{
const Vector3& position =
pTransformComponent->GetTransform().GetTranslation();
bool onFloor = false;
if (position.m_y < FLT_EPSILON)
{
onFloor = true;
}
bool falling = m_acceleration.m_y < 0.0f;
Timer& timer = Timer::GetSingleton();
Vector3 translation = m_velocity;
translation.Multiply(timer.GetTimeSim());
translation.Add(position);
if (falling && translation.m_y < 0.0f)
{
translation.m_y = 0.0f;
}
pTransformComponent->GetTransform().SetTranslation(translation);
Vector3 accel = m_acceleration;
accel.Multiply(timer.GetTimeSim());
m_velocity.Add(accel);
static const float GRAVITY_MULTIPLIER = 15.0f;
static const float GRAVITY_CONSTANT = -9.8f;
m_acceleration.m_y +=
GRAVITY_MULTIPLIER *
GRAVITY_CONSTANT *
timer.GetTimeSim();
if (falling && onFloor)
{
m_acceleration.m_y = 0.0f;
m_velocity.m_y = 0.0f;
}
}
}
}
在清单 6-34 中执行的第一个任务是处理JUMP_EVENT
。当这个事件被发送到MovementComponent
时,我们首先使用component_cast
从所有者对象获取TransformComponent
。现在,我们使用 y 的位置来查看玩家是否在地面上并被允许再次跳跃。我们将在下一章讨论水平碰撞时解决这个问题,但是现在如果玩家被允许跳跃,我们设置他的垂直加速度。
游戏物理学是基于导数和积分的数学概念。物体位置或位移的导数就是它的速度。速度的导数是加速度。鉴于这一事实,如果我们有一个加速度,我们可以积分,以达到期望的速度,我们也可以积分速度,以获得新的位置。
计算加速度的基本方程如下:加速度=力/质量。为了简单起见,我们使用质量为 1 的物体,所以现在我们可以将方程简化为加速度=力。因此,我们可以像处理JUMP_EVENT
时那样直接设置加速度值。清单 6-35 中的代码显示了当前速度位置的积分。
清单 6-35。 整合位置
Vector3 translation = m_velocity;
translation.Multiply(Timer::GetSingleton().GetTimeSim());
translation.Add(position);
这种简单的速度乘以当前时间步长并加上位置的方法是一种简单的数值方法,称为欧拉积分。有很好的理由解释为什么你不在商业游戏中使用欧拉积分,但是为了我们简单的目的和速度,我们在这里使用了它。
如果加速度不恒定并且时间步长逐帧变化,欧拉积分可能不稳定并且给出不可靠的位置,这在我们的游戏场景变得繁忙时肯定会发生。幸运的是,我们的加速是恒定的,我们的简单游戏应该以一贯的高帧速率运行。对于任何想研究更稳定的数值积分技术并强烈推荐用于游戏开发的人来说,你应该研究四阶龙格库塔积分器。
清单 6-36 显示了我们将加速度积分成速度的欧拉积分。
清单 6-36。 用欧拉积分加速
Vector3 accel = m_acceleration;
accel.Multiply(timer.GetTimeSim());
m_velocity.Add(accel);
我们再次乘以当前模拟时间步长,并添加到现有值。
为了让我们的玩家落回地面,我们需要对我们的模型施加重力。同样,我们可以通过使用重力的一个有用特性来稍微简化代码。重力作用于物体,将它们拉向地面。你可能会认为,随着力的方程式将加速度乘以质量,较大的物体可能会下落得更快。事实并非如此,因为物体也有一种被称为惯性的属性,这种属性也与质量有关。因此,重力方程中的质量与惯性方程中的质量相抵消,我们发现地球上所有物体在重力作用下都以大约每秒 9.8 米的速度加速。清单 6-37 展示了我们如何使用这个值让我们的物体落回地面。
清单 6-37。 对加速度应用重力
static const float GRAVITY_MULTIPLIER = 15.0f;
static const float GRAVITY_CONSTANT = -9.8f;
m_acceleration.m_y += GRAVITY_MULTIPLIER * GRAVITY_CONSTANT * timer.GetTimeSim();
此时,我们的跳跃加速度为 80,重力乘数为 15。我完全是通过反复试验得出这些数字的。您可以尝试本章附带的示例代码,增加或减少这些数字,看看它们对我们的跳转模拟有什么影响。
这段代码在收到UPDATE_EVENT
时运行每一帧,所以一旦玩家跳起来,他们就会起来,然后落回地面。用于UPDATE_EVENT
的代码块中的三个 if 语句也有助于通过计算玩家是否在地板上、更新后是否在地板上以及他们是否正在下落(由于 y 方向上的速度为负)来决定跳跃应该何时结束。
本节要看的最后一段代码是我们如何发送跳转事件(见清单 6-38 )。
清单 6-38。 安卓::安卓( )
Android::Android(android_app* pState, unsigned int priority)
: Task(priority)
{
m_pState = pState;
m_pState->onAppCmd = android_handle_cmd;
m_pState->onInputEvent = android_handle_input;
}
我们已经更新了之前的android_app
状态对象来保存另一个函数指针,这一次是为了当游戏的输入事件准备好的时候。清单 6-39 显示了函数声明。
清单 6-39。 安卓 _ 手柄 _ 输入
static int android_handle_input(struct android_app* app, AInputEvent* event)
{
int handled = 0;
if (AInputEvent_getType(event) == AINPUT_EVENT_TYPE_MOTION)
{
int action = AMotionEvent_getAction(event);
if (action == AMOTION_EVENT_ACTION_DOWN)
{
Framework::SendEvent(Framework::JUMP_EVENT);
handled = 1;
}
}
return handled;
}
我们的简单游戏要求玩家只有在接触到屏幕时才能跳跃。当收到AMOTION_EVENT_ACTION_DOWN
时,我们发送我们的JUMP_EVENT
,任何监听该事件的EventHandlers
都将被处理。
现在我们有了一个玩家对象,我们将添加一个遵循路径的 AI 对象。
一个基本的人工智能实体
首先,我们将向Chapter6Task
类添加另一个GameObject
,如清单 6-40 所示。
清单 6-40。 添加一个 AI 对象
private:
Framework::Geometry m_geometry;
Framework::TransformShader m_transformShader;
Framework::GameObject m_playerObject;
Framework::GameObject m_aiObject;
我们将这个对象的设置代码添加到Chapter6Task::Start
,如清单 6-41 所示。
清单 6-41。 更新章节 6Task::Start()
m_playerObject.AddComponent<RenderableComponent>();
RenderableComponent* pRenderableComponent =
component_cast<RenderableComponent>(m_playerObject);
if (pRenderableComponent)
{
Renderable& renderable = pRenderableComponent->GetRenderable();
renderable.SetGeometry(&m_geometry);
renderable.SetShader(&m_transformShader);
Vector4& color = renderable.GetColor();
color.m_x = 0.0f;
color.m_y = 1.0f;
color.m_z = 0.0f;
color.m_w = 1.0f;
Framework::AttachEvent(Framework::RENDER_EVENT, *pRenderableComponent);
}
m_aiObject.AddComponent<TransformComponent>();
pTransformComponent = component_cast<TransformComponent>(m_aiObject);
m_aiObject.AddComponent<PatrolComponent>();
PatrolComponent* pPatrolComponent = component_cast<PatrolComponent>(m_aiObject);
if (pPatrolComponent)
{
Vector3 startPoint(10.0f, -10.0f, 75.0f);
pPatrolComponent->SetStartPoint(startPoint);
Vector3 endPoint(15.0f, 7.5f, 25.0f);
pPatrolComponent->SetEndPoint(endPoint);
pPatrolComponent->SetSpeed(25.0f);
Framework::AttachEvent(UPDATE_EVENT, *pPatrolComponent);
}
m_aiObject.AddComponent<RenderableComponent>();
pRenderableComponent = component_cast<RenderableComponent>(m_aiObject);
if (pRenderableComponent)
{
Renderable& renderable = pRenderableComponent->GetRenderable();
renderable.SetGeometry(&m_geometry);
renderable.SetShader(&m_transformShader);
Vector4& color = renderable.GetColor();
color.m_x = 1.0f;
color.m_y = 0.0f;
color.m_z = 0.0f;
color.m_w = 1.0f;
Framework::AttachEvent(Framework::RENDER_EVENT, *pRenderableComponent);
}
return true;
}
我们给我们的人工智能对象添加了一个TransformComponent
和一个RenderableComponent
,就像我们为玩家做的一样。然而,这一次,我们将敌人的物体设为红色,而玩家的物体设为绿色。
我们为敌人对象添加的新Component
是一个PatrolComponent
,如清单 6-42 所示。这个组件被编写为在空间中的两点之间来回移动。我们设置起点和终点,以及我们希望物体在这里移动的速度。我们还将PatrolComponent
连接到UPDATE_EVENT
。
***清单 6-42。***patrol component 类声明
class PatrolComponent
: public Framework::Component
, public Framework::EventHandler
{
private:
Framework::Vector3 m_direction;
Framework::Vector3 m_startPoint;
Framework::Vector3 m_endPoint;
Framework::Vector3* m_pOriginPoint;
Framework::Vector3* m_pTargetPoint;
float m_speed;
static const unsigned int s_id = 10;
public:
static unsigned int GetId() { return s_id; }
explicit PatrolComponent(Framework::GameObject* pObject);
virtual ∼PatrolComponent();
virtual void Initialize();
virtual void HandleEvent(Framework::Event* pEvent);
void SetStartPoint(Framework::Vector3& startPoint);
void SetEndPoint(Framework::Vector3& endPoint);
void SetSpeed(float speed) { m_speed = speed; }
};
PatrolComponent
有三个Vector3
字段,一个用于当前运动方向,一个用于起点,一个用于终点。我们还有两个Vector3
指针和一个float
速度指针。我们将看看这些指针是如何在清单 6-45 中使用的。首先,我们需要看一下SetStartPoint
方法 ,如清单 6-43 所示。
清单 6-43. 巡逻组件:设置起点( )
void PatrolComponent::SetStartPoint(Vector3& startPoint)
{
m_startPoint.Set(startPoint);
TransformComponent* pTransformComponent = component_cast<TransformComponent>(GetOwner());
assert(pTransformComponent);
if (pTransformComponent)
{
pTransformComponent->GetTransform().SetTranslation(m_startPoint);
}
}
SetStartPoint
设置m_startPoint
字段以匹配通过参数传入的Vector3
。我们还使用这个向量来设置对象的TransformComponent
的位置。
接下来是SetEndPoint
方法 ,如清单 6-44 所示。
***清单 6-44。***patrol component::set endpoint()
void PatrolComponent::SetEndPoint(Vector3& endPoint)
{
assert(m_startPoint.LengthSquared() > FLT_EPSILON);
m_endPoint.Set(endPoint);
m_direction = m_endPoint;
m_direction.Subtract(m_startPoint);
m_direction.Normalise();
m_pOriginPoint = &m_startPoint;
m_pTargetPoint = &m_endPoint;
}
SetEndPoint
开头的断言用于在SetStartPoint
方法未被调用时警告我们。我们需要已经设置好的起点,这样我们就可以计算初始的行进方向。我们通过从终点减去起点来实现,结果存储在m_direction
中。方向是标准化的,这样当组件更新时,我们可以使用单位法线作为参数线。最后,我们将起始点的地址存储在m_pOriginPoint
指针中,将结束点的地址存储在m_pTargetPoint
指针中。现在我们准备更新我们的PatrolComponent
,如清单 6-45 所示。
***清单 6-45。***patrol component::handle event()
void PatrolComponent::HandleEvent(Event* pEvent)
{
if (pEvent->GetID() == Framework::UPDATE_EVENT && m_pTargetPoint)
{
assert(m_direction.LengthSquared() > FLT_EPSILON);
assert(m_speed > 0.0f);
TransformComponent* pTransformComponent =
component_cast<TransformComponent>(GetOwner());
assert(pTransformComponent);
if (pTransformComponent)
{
Vector3 translation = m_direction;
translation.Multiply(m_speed * Timer::GetSingleton().GetTimeSim());
translation.Add(pTransformComponent->GetTransform().GetTranslation());
pTransformComponent->GetTransform().SetTranslation(translation);
Vector3 distance = *m_pTargetPoint;
distance.Subtract(translation);
if (distance.LengthSquared() < 2.0f)
{
Vector3* temp = m_pTargetPoint;
m_pTargetPoint = m_pOriginPoint;
m_pOriginPoint = temp;
m_direction = *m_pTargetPoint;
m_direction.Subtract(*m_pOriginPoint);
m_direction.Normalise();
}
}
}
}
如果我们收到一个UPDATE_EVENT
并且我们有一个有效的m_pTargetPoint
,我们决定当前组件是否准备好被处理。如果我们这样做了,我们就断言 m_direction 和m_speed
字段的有效性,以确保我们在运行时没有任何问题。
一旦我们对准备好进行处理感到满意,我们就为对象的所有者检索TransformComponent
。如果我们给一个没有TransformComponent
的对象添加了一个PatrolComponent
,这里的断言将被触发。我们通过使用方向乘以速度和当前模拟时间增量来更新Transform
的平移元素。然后,我们将当前帧的速度添加到对象的旧位置。
一旦位置被更新,我们测试看对象是否已经到达目标点。我们这样做是通过从我们当前行进的位置减去物体的新位置,并检查剩余向量的平方长度是否小于 2。
如果我们已经到达目标点,我们交换m_pOriginPoint
和m_pTargetPoint
地址并重新计算行进方向。这就把我们的对象送回了它刚来的地方。
我相信你已经和敌人玩过基本的游戏,这些敌人看起来只是在很少实际智力的情况下来回移动,现在你已经看到了在你自己的游戏中实现相同行为的一种方法。
在开发游戏引擎的过程中,我们有一个玩家对象呈现在屏幕上。游戏当前状态的截图如图 6-2 所示。
图 6-2 。机器人信使的当前状态
摘要
这是另一场关于大公司如何制作游戏的旋风之旅。我们刚刚谈到的一些主题从引擎系统和图形编程到游戏性和人工智能编程,甚至是游戏物理学的简要介绍。这些主题中的每一个都有很多关于它们的书籍,没有办法在一个章节中对它们进行公正的讨论,但是我们已经涵盖了足够的基础知识,可以让我们开始自己的项目。
组件系统是创建游戏实体的前沿方法。这种方法是创建灵活的、可扩展的系统所需要的基础。通过使用这样的系统,我们可以避免规划和维护复杂的继承层次树所涉及的复杂性,以及当不同分支上的实体需要类似的行为时所需要的重复代码。它还允许我们在运行时通过动态添加和删除组件来更新对象的行为。这在人工智能开发中可能是一个强大的方法,你可以根据一个复杂的玩家或人工智能对象的当前状态来添加或删除组件。
我们的事件系统同样降低了代码的复杂性,使得在整个游戏中响应事件变得非常容易。到目前为止,我们看到的简单例子包括响应输入、更新游戏对象和渲染这些对象。向前发展,我们将能够添加越来越多的事件。游戏事件,比如播放声音和通知物体碰撞,都可以用我们的事件系统来处理。这个系统也给单个对象一定程度的操作控制权。处理我们在MovementComponent
中接收到多个跳转事件的情况的一个有效的替代方法是,在第一次处理跳转事件之后分离它,直到我们准备好接收另一个跳转事件。这样的系统有许多可能的用途,我们将在以后的工作中了解更多。
现在我们在屏幕上有了两个物体,是时候看看在它们周围建立一个世界,让我们的应用感觉更像一个游戏。在下一章,我们将通过一个碰撞系统,然后建立一个游戏关卡。
七、建立带有碰撞的游戏关卡
几乎每个游戏的一个关键部分是能够检测不同物体之间的碰撞并做出反应。即使回过头来看看经典游戏,如 Pong 或太空入侵者,我们也可以看到碰撞已经成为游戏体验的一部分。
使用数学来处理碰撞检测也就不足为奇了。如果您必须处理对象的多边形网格之间的碰撞,涉及的一些数学会特别复杂。在这一章中,我们将在更高的层次上研究碰撞。包围体被用作一种测试,以更详细地检测两个对象是否是碰撞的候选对象。我们游戏的设计足够简单,这种程度的碰撞检测将足够好。
检测碰撞也可能是一件计算量很大的事情,这促使我们寻找优化引擎中碰撞检测阶段的方法。我们将通过把我们的场景分割成离散的部分并只在这些部分内检查物体之间的碰撞来实现这一点。这被称为碰撞检测算法的广义阶段,而对单个对象的测试被称为狭义阶段。
一旦我们确定物体发生了碰撞,我们必须进行碰撞响应。这将在我们的代码中通过发送冲突事件来处理。我们在前一章中已经讨论了事件和组件系统,并且在我们实现碰撞检测系统时将会以这些为基础。
既然我们知道了本章要看什么,那就让我们开始吧。
用碰撞数据表示游戏世界对象
随着 GPU 能力的提高,我们用来渲染游戏对象的网格变得越来越复杂。这意味着用于渲染对象的网格不再适用于碰撞检测系统。检测非常复杂的物体之间的碰撞可能非常耗时,以至于实现实时帧速率简直是不可能的。对于我们的简单游戏,我们不需要在那个细节层次上检测不同对象之间的碰撞,所以我们可以通过简单地比较我们的对象的包围盒来早期优化碰撞检测过程。
边界体积是代表模型所占空间范围的形状。球体、立方体和胶囊等 3D 形状通常用于表示这些体积。对于我们的例子,我们将使用一种称为轴对齐包围盒(AABB)的包围体。AABB 是一个长方体,其侧面平行于 x、y 和 z 轴。由于所有 AABBs 的所有边都平行于这些轴,我们可以使用一种非常快速的算法来检测我们的两个对象是否碰撞。这种算法被称为分离轴定理。这听起来比实际要复杂得多,事实上,你已经在我们在《??》第二章中开发的简单的突围游戏中实现了 2D 版本的算法。
要了解这个算法是如何工作的,最简单的方法就是看一下我们用来实现它的代码。清单 7-1 显示了CollisionComponent
,我们将用它来添加我们的对象。
清单 7-1。 碰撞组件类声明
class CollisionComponent
: public Component
{
private:
static const unsigned int s_id = 2;
Vector3 m_min;
Vector3 m_max;
public:
static unsigned int GetId() { return s_id; }
explicit CollisionComponent(GameObject* pOwner);
virtual ∼CollisionComponent();
virtual void Initialize();
void SetMin(const Vector3& point) { m_min = point; }
void SetMax(const Vector3& point) { m_max = point; }
bool Intersects(CollisionComponent& target);
};
我们的CollisionComponent
有两个Vector3
对象,它们将用于存储包围体的范围。m_min Vector3
对象将存储 x、y 和 z 轴的所有最小值,而m_max
将存储 x、y 和 z 轴的最大值。
Intersect
方法是执行组件工作和执行分离轴算法的地方。在我们看实现这一点的代码之前,我们先来看看这个算法在理论上是如何工作的。图 7-1 显示了我们将用来思考分离轴算法的图表。
图 7-1 。分离轴定理
顾名思义,我们通过每次在每个单独的轴上寻找它们之间的间隙来检测重叠的对象。对于轴对齐的边界框,体积的每条边都将平行于世界空间中相应的轴。如果前面的图是沿着 z 轴看的,那么 x 轴会向右,y 轴向上。该图将允许我们在 x 轴上可视化 AABBs 之间的间隙。
如果我们对照第一条线测试第二条线,那么我们会说我们知道它们没有重叠,因为这条线的左边比第一条线的右边更靠右。如果我们在相反的情况下测试这些线,并将第一条线与第二条线进行比较,我们会说它们没有重叠,因为第一条线的右边比第二条线的左边更靠左。
在游戏运行时,我们不能确定哪个物体在哪一边,所以我们检查这两种情况。我们还对 y 轴和 z 轴上的边缘重复这个测试。现在也清楚了为什么我们需要将线的最小值和最大值存储在组件中它们各自的Vector3
对象中:我们需要知道线的哪一侧最左边,哪一侧最右边,以便算法容易工作。
现在我们已经讨论了算法背后的理论,让我们来看看CollisionComponent::Intersect
的实现(见清单 7-2 )。
***清单 7-2。***collision component::Intersect()
bool CollisionComponent::Intersects(CollisionComponent& target)
{
bool intersecting = true;
Vector3 thisMin = m_min;
Vector3 thisMax = m_max;
TransformComponent* pThisTransformComponent =
component_cast<TransformComponent>(GetOwner());
if (pThisTransformComponent)
{
Transform& transform = pThisTransformComponent->GetTransform();
thisMin.Add(transform.GetTranslation());
thisMax.Add(transform.GetTranslation());
}
Vector3 targetMin = target.m_min;
Vector3 targetMax = target.m_max;
TransformComponent* pTargetTransformComponent =
component_cast<TransformComponent>(target.GetOwner());
if (pTargetTransformComponent)
{
Transform& transform = pTargetTransformComponent->GetTransform();
targetMin.Add(transform.GetTranslation());
targetMax.Add(transform.GetTranslation());
}
if (thisMin.m_x > targetMax.m_x ||
thisMax.m_x < targetMin.m_x ||
thisMin.m_y > targetMax.m_y ||
thisMax.m_y < targetMin.m_y ||
thisMin.m_z > targetMax.m_z ||
thisMax.m_z < targetMin.m_z)
{
intersecting = false;
}
return intersecting;
}
在Intersect
方法中要完成的第一个任务是转换每个对象的 AABB。我们简单地将测试中每个对象的位置从TransformComponent
添加到它们的边界框中,而不是应用整个变换。如果应用整个变换,我们将最终旋转轴对齐的框,这将把它变成定向的边界框,并且我们的 AABB 测试将不再可靠。我们在这里也忽略了变换的比例,因为我们知道我们没有使用它,但是如果你决定缩放你的对象,这也需要应用。
然后我们有一个if
测试,确定对象是否没有重叠。回想一下图 7-1 中的图表,我们可以看到最小值代表线条的左边,最大值代表右边。我们测试两种情况中的每一种,在这两种情况下,我们知道对象对于每个轴都不重叠。首先,我们检查第一个对象的左边是否比第二个对象的右边更靠右。然后我们检查第一个对象的右边是否比第二个对象的左边更靠左。如果这两个条件中的任何一个为真,那么我们的对象没有重叠。然后对 y 轴和 z 轴完成相同的测试。如果这些测试中的任何一个是肯定的,我们知道我们的对象没有重叠。
这就是使用轴对齐的边界框检测物体间碰撞的全部内容。碰撞检测并不总是如此琐碎,但这是一个很好的起点,因为它涵盖了我们如何检测世界上两个对象之间的碰撞的基础知识。在这一点上,我们仍然没有真正的世界,所以我们将在下一节通过创建一个级别来解决这个问题。
建立游戏关卡
现在我们可以让物体碰撞,是时候考虑我们将如何在游戏世界中定位我们的物体了。侧滚游戏已经存在了很长很长一段时间,许多围绕着在侧滚游戏关卡中放置物体的挑战在二十多年前就已经解决了。一个最好的例子就是超级马里奥兄弟系列,它用统一大小的积木来建造关卡。这些块被称为 tiles,它们允许游戏开发者解决许多问题,包括纹理的有限内存、有限的调色板和有限的系统 RAM 来存储级别数据信息。因此,用重复图案的可重复使用的积木来建造关卡是一种非常有效的技术,可以建造比其他方式更大的游戏关卡。
我们的简单游戏将基于同样的基本技术构建。出于本书的目的,我们的设计被有意地写得尽可能简单,以展示用于构建游戏的技术。我们将使用的单块积木只是一个供玩家站立的平台。
在上一章中,我们直接在Chapter6Task
类中创建了游戏对象。这一次,我们将创建一个类来包含我们的级别,因为这将允许我们在将来创建多个级别。清单 7-3 显示了DroidRunnerLevel
类的声明。
***清单 7-3。***DroidRunnerLevel 类
class DroidRunnerLevel
: public Framework::EventHandler
{
private:
Framework::CollisionComponent* m_pPlayerCollisionComponent;
Framework::Geometry m_sphereGeometry;
Framework::Geometry m_cubeGeometry;
Framework::TransformShader m_transformShader;
enum TileTypes
{
EMPTY = 0,
BOX,
AI,
PLAYER
};
typedef std::vector<Framework::GameObject*> GameObjectVector;
typedef GameObjectVector::iterator GameObjectVectorIterator;
GameObjectVector m_levelObjects;
void SetObjectPosition(
Framework::GameObject* pObject,
const unsigned int row,
const unsigned int column);
void AddMovementComponent(Framework::GameObject* pObject);
void AddCollisionComponent(
Framework::GameObject* pObject,
const Framework::Vector3& min,
const Framework::Vector3& max);
void AddPatrolComponent(
Framework::GameObject* pObject,
const unsigned int startRow,
const unsigned int startColumn,
const unsigned int endRow,
const unsigned int endColumn);
void AddRenderableComponent(
Framework::GameObject* pObject,
Framework::Geometry& geometry,
Framework::Shader& shader,
Framework::Vector4& color);
static const float TILE_WIDTH = 6.0f;
static const float TILE_HEIGHT = 6.0f;
Framework::Vector3 m_origin;
public:
DroidRunnerLevel();
∼DroidRunnerLevel();
void Initialize(const Framework::Vector3& origin);
virtual void HandleEvent(Framework::Event* pEvent);
};
DroidRunnerLevel
类的私有部分是我们现在为场景找到Geometry
和Shader
对象的地方。这一章我们还有一个球体、立方体和TransformShader
。除此之外,我们还存储了一个指向玩家对象的CollisionComponent;
的指针,我们将在讨论DroidRunnerLevel::HandleEvent
的代码时看看为什么要这样做。
枚举TileTypes
定义了我们将在游戏关卡中支持的不同类型的方块。我们需要一个玩家牌,一个 AI 牌和一个盒子牌。这些是我们用来创建关卡的基本构件。
我们定义了一个vector
和iterator
来存储属于这个级别的GameObjects
,然后还定义了一些 helper 方法,这些方法将在这个级别初始化时用来构造对象。
接下来,我们有两个静态的floats
,它存储瓷砖的 2D 尺寸,还有一个Vector3
,它存储空间中定义关卡原点的点。我们所有的对象都将从这个原点偏移创建。
这个类需要的唯一公共方法是constructor
和destructor
、Initialize
和HandleEvent
。像往常一样,HandleEvent
是在EventHandler
父类中定义的被覆盖的虚方法。
在清单 7-4 中列出的Initialize
方法贯穿了我们关卡的设置。
清单 7-4。 DroidRunnerLevel::初始化
void DroidRunnerLevel::Initialize(const Vector3& origin)
{
m_sphereGeometry.SetVertexBuffer(sphereVerts);
m_sphereGeometry.SetNumVertices(sizeof(sphereVerts) / sizeof(sphereVerts[0]));
m_sphereGeometry.SetIndexBuffer(sphereIndices);
m_sphereGeometry.SetNumIndices(sizeof(sphereIndices) / sizeof(sphereIndices[0]));
m_sphereGeometry.SetName("android");
m_sphereGeometry.SetNumVertexPositionElements(3);
m_sphereGeometry.SetVertexStride(0);
m_cubeGeometry.SetVertexBuffer(cubeVerts);
m_cubeGeometry.SetNumVertices(sizeof(cubeVerts) / sizeof(cubeVerts[0]));
m_cubeGeometry.SetIndexBuffer(cubeIndices);
m_cubeGeometry.SetNumIndices(sizeof(cubeIndices) / sizeof(cubeIndices[0]));
m_cubeGeometry.SetName("cube");
m_cubeGeometry.SetNumVertexPositionElements(3);
m_cubeGeometry.SetVertexStride(0);
m_origin.Set(origin);
CollisionManager::GetSingleton().AddCollisionBin();
const Vector3 min(–3.0f, –3.0f, –3.0f);
const Vector3 max(3.0f, 3.0f, 3.0f);
const unsigned char tiles[] =
{
EMPTY, EMPTY, EMPTY, EMPTY, AI, AI, AI, AI,
EMPTY, EMPTY, EMPTY, EMPTY, BOX, BOX, BOX, BOX,
EMPTY, PLAYER, EMPTY, EMPTY, EMPTY, EMPTY, EMPTY, EMPTY,
BOX, BOX, BOX, BOX, BOX, BOX, BOX, BOX
};
const unsigned int numTiles = sizeof(tiles) / sizeof(tiles[0]);
const unsigned int numRows = 4;
const unsigned int rowWidth = numTiles / numRows;
for (unsigned int i=0; i<numTiles; ++i)
{
if (tiles[i] == BOX)
{
const unsigned int row = i / rowWidth;
const unsigned int column = i % rowWidth;
GameObject* pNewObject = new GameObject();
SetObjectPosition(pNewObject, row, column);
AddCollisionComponent(pNewObject, min, max);
Vector4 color(0.0f, 0.0f, 1.0f, 1.0f);
AddRenderableComponent(
pNewObject,
m_cubeGeometry,
m_transformShader,
color);
m_levelObjects.push_back(pNewObject);
}
else if (tiles[i] == PLAYER)
{
const unsigned int row = i / rowWidth;
const unsigned int column = i % rowWidth;
GameObject* pNewObject = new GameObject();
SetObjectPosition(pNewObject, row, column);
AddMovementComponent(pNewObject);
AddCollisionComponent(pNewObject, min, max);
MovementComponent* pMovementComponent =
component_cast<MovementComponent>(pNewObject);
m_pPlayerCollisionComponent =
component_cast<CollisionComponent>(pNewObject);
if (pMovementComponent && m_pPlayerCollisionComponent)
{
m_pPlayerCollisionComponent->AddEventListener(pMovementComponent);
}
Vector4 color(0.0f, 1.0f, 0.0f, 1.0f);
AddRenderableComponent(
pNewObject,
m_sphereGeometry,
m_transformShader,
color);
m_levelObjects.push_back(pNewObject);
}
else if (tiles[i] == AI)
{
const unsigned int row = i / rowWidth;
const unsigned int column = i % rowWidth;
unsigned int patrolEndRow = 0;
unsigned int patrolEndColumn = 0;
for (unsigned int j=i; j<numTiles; ++j)
{
if (tiles[j] != AI)
{
i = j;
--j;
patrolEndRow = j / rowWidth;
patrolEndColumn = j % rowWidth;
break;
}
}
GameObject* pNewObject = new GameObject();
SetObjectPosition(pNewObject, row, column);
AddCollisionComponent(pNewObject, min, max);
AddPatrolComponent(pNewObject, row, column, patrolEndRow, patrolEndColumn);
Vector4 color(1.0f, 0.0f, 0.0f, 1.0f);
AddRenderableComponent(
pNewObject,
m_sphereGeometry,
m_transformShader,
color);
}
}
Renderer* pRenderer = Renderer::GetSingletonPtr();
if (pRenderer)
{
pRenderer->AddShader(&m_transformShader);
}
}
Initialize
从初始化Geometry
和Shader
类开始,就像我们在第六章中所做的一样。
然后,我们用作为参数传递给方法的值origin
初始化原点向量m_origin
的位置。
然后一个新的碰撞箱被添加到CollisionManager
;
中,我们将在清单 7-15 中覆盖CollisionManager
。
然后,初始化一个由unsigned char
值组成的数组,以包含我们想要为该级别构建的图块布局。在这里,我们指定了人工智能应该在哪里巡逻,世界碰撞盒应该放在哪里,以及玩家的起始位置应该在哪里。现在,我们已经将该层分为四行八列。这个级别足够大,可以放在一个屏幕上;我们将在第八章的中扩展这一等级以覆盖多个屏幕。
然后通过将平铺数组的大小除以单个元素的大小来计算平铺的数量,并且我们使用我们将总是有四行的事实来计算我们当前在平铺数组中定义的列的数量。
然后使用一个for
循环来遍历数组的每个元素。当我们遇到一个空瓷砖,没有采取任何行动。如果瓷砖是一个盒子,那么我们将在世界的这一点上创建一个盒子对象。首先,我们需要计算我们占据了哪一行和哪一列。我们可以通过将当前索引除以行的宽度来计算出该行。例如,元素 16 是第三行的第一个瓦片:这将给出第 0 列和第 2 行(因为我们从第 0 行开始计数)。我们使用除法运算符 16 / 8 = 2 来计算行,使用模(或余数)运算符 16 % 8 = 0 来计算列。
一个新的游戏对象被创建,我们在这个对象上设置我们在 ,
方法中得到的位置,如清单 7-5 所示。
清单 7-5。 自动喷水灭火::SetObjectPosition
void DroidRunnerLevel::SetObjectPosition(
Framework::GameObject* pObject,
const unsigned int row,
const unsigned int column)
{
assert(pObject);
pObject->AddComponent<TransformComponent>();
TransformComponent* pTransformComponent = component_cast<TransformComponent>(pObject);
if (pTransformComponent)
{
Vector3 translation(m_origin);
translation.m_x += TILE_WIDTH * column;
translation.m_y -= TILE_HEIGHT * row;
pTransformComponent->GetTransform().SetTranslation(translation);
}
}
SetObjectPosition
是一个帮助器方法,它给我们的对象添加了一个TransformComponent
。然后TransformComponent
?? 的平移被设置到世界空间中我们希望我们的对象占据的位置。我们通过将瓷砖的宽度乘以柱索引,然后将其加到标高原点的 x 位置来计算。类似地,我们通过将瓦片的高度乘以行索引并从原点 y 位置减去该值来计算 y 位置。
在SetObjectPosition
中设置好对象的位置后,DroidRunnerLevel
调用对象上的AddCollisionComponent
;该方法如清单 7-6 所示。
清单 7-6。 【自动喷水灭火::加压素组分
void DroidRunnerLevel::AddCollisionComponent(
Framework::GameObject* pObject,
const Framework::Vector3& min,
const Framework::Vector3& max)
{
assert(pObject);
pObject->AddComponent<CollisionComponent>();
CollisionComponent* pCollisionComponent = component_cast<CollisionComponent>(pObject);
if (pCollisionComponent)
{
pCollisionComponent->SetMin(min);
pCollisionComponent->SetMax(max);
AttachEvent(COLLISION_EVENT, *pCollisionComponent);
CollisionManager::GetSingleton().AddObjectToBin(0, pCollisionComponent);
}
}
这是另一个简单的助手方法。我们简单地给对象添加一个CollisionComponent
,并初始化它的min
和max
字段。在这一点上,我们确实需要对CollisionComponent
进行更新。我们注册了一个新的活动类型名称COLLISION_EVENT,
,并继承了EventHandler
的CollisionComponent
。我们来看看清单 7-28 中的CollisionComponent::HandleEvent
。CollisionComponent
也被添加到索引为 0 的CollisionManager
的 bin 中。我们将在本章的下一节讨论CollisionManager
。
应用于盒子的最后一个助手方法是AddRenderableComponent
,如清单 7-7 所示。
***清单 7-7。***DroidRunnerLevel::AddRenderableComponent
void DroidRunnerLevel::AddRenderableComponent(
GameObject* pObject,
Geometry& geometry,
Shader& shader,
Vector4& color)
{
assert(pObject);
pObject->AddComponent<RenderableComponent>();
RenderableComponent* pRenderableComponent = component_cast<RenderableComponent>(pObject);
if (pRenderableComponent)
{
Renderable& renderable = pRenderableComponent->GetRenderable();
renderable.SetGeometry(&geometry);
renderable.SetShader(&shader);
Vector4& renderableColor = renderable.GetColor();
renderableColor.Set(color);
Framework::AttachEvent(Framework::RENDER_EVENT, *pRenderableComponent);
}
}
这是另一个简单的方法,向我们的对象添加一个RenderableComponent
并初始化它的字段。在盒子的例子中,我们指定m_cubeGeometry
、m_transformShader
和蓝色作为这个方法的参数。
DroidRunnerLevel::Initialize
当磁贴被设置为代表一个玩家时,也可以创建一个玩家对象。初始化一个盒子和初始化一个玩家之间唯一的区别是玩家由球体几何图形和绿色表示,并且调用方法AddMovementComponent
作为参数。这个方法如清单 7-8 所示。
清单 7-8。 自动喷水灭火::AddMovementComponent
void DroidRunnerLevel::AddMovementComponent(GameObject* pObject)
{
assert(pObject);
pObject->AddComponent<MovementComponent>();
MovementComponent* pMovementComponent = component_cast<MovementComponent>(pObject);
if (pMovementComponent)
{
AttachEvent(JUMP_EVENT, *pMovementComponent);
AttachEvent(UPDATE_EVENT, *pMovementComponent);
}
}
正如其他助手方法一样,我们将目标 c omponent
添加到对象中,在本例中是MovementComponent
,并初始化任何数据。MovementComponent 唯一需要的初始化是附加JUMP_EVENT
和UPDATE_EVENT
消息。
在前一章的清单 6-34 中,我们看到如果MovementComponent
在 y 轴上移动到 0 以下,我们阻止了它的下落。在这一章中,如果玩家在一个盒子上休息,我们想要阻止他们掉下来。我们将在清单 7-24 和 7-26 中看看我们是如何做到这一点的,但是现在我们可以看到初始化代码将MovementComponent
作为一个Listener
对象添加到播放器的CollisionComponent
中。
我们要处理的最后一种牌是 AI 类型。我们的 AI 图块不覆盖单个图块,而是覆盖一系列图块,这些图块表示我们的 AI 对象应该巡视的路径。清单 7-9 恢复了计算出巡逻路径起点和终点的代码。
清单 7-9。 计算 AI 巡逻路径
const unsigned int row = i / rowWidth;
const unsigned int column = i % rowWidth;
unsigned int patrolEndRow = 0;
unsigned int patrolEndColumn = 0;
for (unsigned int j=i; j<numTiles; ++j)
{
if (tiles[j] != AI)
{
i = j;
--j;
patrolEndRow = j / rowWidth;
patrolEndColumn = j % rowWidth;
break;
}
}
正如你所看到的,清单 7-9 从计算当前图块的行和列开始。然后我们使用一个内部循环,直到我们找到一个非人工智能瓷砖。此时,我们增加i
以跳过 AI 图块,然后减少j
以使其指向巡视路径中的最后一个图块。然后计算该端点的行和列。这种定义路径的基本方法有一个缺点,那就是在路径中的最后一个图块之后,我们必须总是有一个非 AI 图块。这包括在行的末尾结束的路径;下一行的第一个图块不能是 AI 路径,否则此代码会将其视为前一行路径的延续,此外,该级别中的最后一个图块不能是 AI 图块,否则路径不会终止,并将使用图块 0,0 作为终点。
就像盒子和玩家对象一样,AI 对象有一个由我们的助手方法添加的TransformComponent
、CollisionComponent,
和RenderableComponent
。
我们还使用AddPatrolComponent
给我们的 AI 对象添加一个PatrolComponent
,如清单 7-10 中的所示。
清单 7-10。 自动喷水灭火::addpatrolcomponent
void DroidRunnerLevel::AddPatrolComponent(
Framework::GameObject* pObject,
const unsigned int startRow,
const unsigned int startColumn,
const unsigned int endRow,
const unsigned int endColumn)
{
assert(pObject);
pObject->AddComponent<PatrolComponent>();
PatrolComponent* pPatrolComponent = component_cast<PatrolComponent>(pObject);
if (pPatrolComponent)
{
Vector3 startPoint(m_origin);
startPoint.m_x += TILE_WIDTH * startColumn;
startPoint.m_y -= TILE_HEIGHT * startRow;
Vector3 endPoint(m_origin);
endPoint.m_x += TILE_WIDTH * endColumn;
endPoint.m_y -= TILE_HEIGHT * endRow;
pPatrolComponent->SetStartPoint(startPoint);
pPatrolComponent->SetEndPoint(endPoint);
pPatrolComponent->SetSpeed(12.0f);
AttachEvent(UPDATE_EVENT, *pPatrolComponent);
}
}
AddPatrolComponent
向对象添加一个PatrolComponent
并初始化开始和结束字段。起点和终点在世界空间中的位置使用与我们用来定位物体的TransformComponents
相同的方法来计算。
最后但同样重要的是,DroidRunnerLevel::Initialize
将m_transformShader
添加到Renderer
中。
这个方法包含了我们构建关卡所需的所有代码。在这一点上,我们处在这样一个位置,我们已经有了对象,但是仍然没有一个方法来检测和响应它们之间的交互。在下一节中,我们将看看如何有效地确定物体是否发生了碰撞。
宽相位滤波
游戏关卡可以包含大量的GameObject
。检测物体间碰撞的最简单方法是暴力操作。这包括将场景中的每个对象与其他对象进行对比测试。检测物体之间的碰撞可能是一个计算量很大的过程。例如,现代 FPS 游戏中使用的大型游戏引擎可能具有大量可破坏的对象,这些对象必须与世界上的大量子弹对象进行测试。在这些可破坏的游戏环境中,计算撞击的点和准确时间是必不可少的,因为玩家可以很快判断出某个物体是否以正确的方式变形,或者他们的射击是否没有针对正确的身体部位。
当使用强力方法 时,要执行的碰撞测试的数量使用下面的等式 x(x–1)/2 以二次方增加。对于 10 个对象,这给出了要执行的 45 个测试,这还不算太坏。对于 1,000 个对象,您需要测试 499,500 次。
减少测试对象数量的过程被称为宽相位过滤。对象的这种传递通常由空间算法组成。这意味着我们利用对象相距较远的事实来忽略这些对象之间的任何碰撞测试,只考虑靠近的对象之间的碰撞。
四叉树和八叉树
四叉树是一种将 2D 空间细分成相等大小的空间的数据结构;八叉树执行相同的工作,但是包括第三维。
如图 7-2 所示,一个四叉树被用来在每一层将一个 2D 空间细分成大小相等的部分。首先,外部部分被水平和垂直分割,形成四个四分之一。然后,根据每个部分中存在的对象数量来分割每个四分之一。给定区域中存在的对象越多,我们将使用越多的细分来减少对象之间的碰撞次数。
图 7-2 。四叉树
八叉树以完全相同的方式工作,但是将 3D 空间分割成相等大小的立方体,每个部分有多达八个分割,而不是四叉树使用的四个分割。
二元空间划分
二进制空间划分(或 BSP )在细分空间方面的工作方式类似于四叉树和八叉树。 Doom 和 Quake 使用这种算法来渲染他们的场景,因为他们在剔除不应在其 3D 软件渲染引擎中渲染的几何图形方面非常有效。
虽然 BSP 由于其对硬件 3D 加速器的低效使用而不再被用作渲染算法,但它仍然是加速涉及静态几何的碰撞检测的有效选择。最常见的 BSP 类型包括使用平面分割几何体。对于这项任务来说,平面是一个有效的几何体,因为它有一个简单的测试来确定对象位于平面的哪一侧。
BSP 结构是在游戏运行之前离线生成的,并且算法通过拾取根多边形、构建多边形所在的平面、然后继续拾取平面每一侧的多边形以构建新的平面来运行。重复该过程,直到细分达到足够精细的细节级别。
如果您正在使用大型网格构建大型室外层级,这是一种有效的技术。
Droid Runner 中的宽相位滤波
我们对宽相位滤波的需求要小得多。我们的关卡有一个固定的高度,所有的物体都在一个 2D 平面上,所以我们可以把关卡分成大小相等的箱子。我们已经看到,我们的级别是用一个分为行和列的数组定义的,所以我们将对每八列使用一个 bin。
碰撞箱
我们来看看清单 7-11 中的碰撞库代码。
清单 7-11。 碰撞宾类声明
class CollisionBin
{
private:
typedef std::vector<CollisionComponent*> CollisionComponentVector;
typedef CollisionComponentVector::iterator CollisionComponentVectorIterator;
CollisionComponentVector m_collisionObjects;
CollisionComponentVectorIterator m_currentObject;
public:
CollisionBin();
∼CollisionBin();
void AddObject(CollisionComponent* pCollisionComponent);
CollisionComponent* GetFirst();
CollisionComponent* GetNext();
};
这是一个简单的类。作为一个 bin,它的任务只是存储一个对象集合,并为这些对象提供访问器方法。在这种情况下,CollisionBin
存储了一个CollisionComponent
指针的vector
。bin 存储一个 current iterator
,用于向调用类提供第一个和下一个对象。
清单 7-12 显示了向 CollisionBin 添加一个新对象所需的代码。
清单 7-12。 碰撞 Bin::AddObject
void CollisionBin::AddObject(CollisionComponent* pCollisionComponent)
{
m_collisionObjects.push_back(pCollisionComponent);
}
方法AddObject
有一个简单的工作:它将传递的CollisionComponent
指针添加到它的数组中。
清单 7-13 展示了GetFirst
如何将内部迭代器设置为vector
的开始。return
语句使用三元运算符来确定iterator
是否指向有效对象。如果是,我们返回被解引用的iterator;
,如果不是,我们返回NULL
。
清单 7-13。 碰撞 Bin::GetFirst
CollisionComponent* CollisionBin::GetFirst()
{
m_currentObject = m_collisionObjects.begin();
return m_currentObject != m_collisionObjects.end()
? *m_currentObject
: NULL;
}
在清单 7-14 中,我们在方法GetNext
中两次测试有效对象的当前iterator
。在尝试递增之前,我们需要确保迭代器不在vector
的末尾,这样我们就可以确保不会离开末尾。一旦我们增加了迭代器,在返回解引用的对象或NULL
之前,我们再次检查迭代器是否有效。
***清单 7-14。***collision bin::get next
CollisionComponent* CollisionBin::GetNext()
{
CollisionComponent* pRet = NULL;
if (m_currentObject != m_collisionObjects.end())
{
++m_currentObject;
pRet = m_currentObject != m_collisionObjects.end()
? *m_currentObject
: NULL;
}
return pRet;
}
箱本身不执行任何碰撞测试;它们只是存储算法。实际测试将由CollisionManager
执行。
碰撞管理器
CollisionManager
,其声明在清单 7-15 中描述,负责存储碰撞箱,并为其余代码提供碰撞测试的接口。
***清单 7-15。***collision manager 类声明
class CollisionManager
: public Singleton<CollisionManager>
{
private:
typedef std::vector<CollisionBin> CollisionBinVector;
CollisionBinVector m_collisionBins;
public:
CollisionManager();
∼CollisionManager();
void AddCollisionBin();
void AddObjectToBin(const unsigned int binIndex, CollisionComponent* pObject);
void TestAgainstBin(const unsigned int binIndex, CollisionComponent* pObject);
};
我们在这个类中再次使用 vector,这次存储的是一个由CollisionBin
实例组成的 vector。我们提供了一些公共方法,用于创建新的容器,将CollisionComponents
添加到容器中,以及针对容器中的所有其他对象测试单个对象。
在AddCollisionBin,
中,我们将新的箱子推到vector
的后面。这显示在清单 7-16 中。
清单 7-16。 碰撞管理器::addcollisionbin
void CollisionManager::AddCollisionBin()
{
m_collisionBins.push_back();
}
清单 7-17 中描述的AddObjectToBin
断言所提供的索引小于 bin vector
的大小。然后它调用在binIndex
找到的 bin 上的AddObject
。
清单 7-17。 碰撞管理器::AddObjectToBin
void CollisionManager::AddObjectToBin(const unsigned int binIndex, CollisionComponent* pObject)
{
assert(binIndex < m_collisionBins.size());
m_collisionBins[binIndex].AddObject(pObject);
}
窄相位碰撞检测
窄相位碰撞检测与加速确定两个特定物体是否碰撞的过程有关。过滤碰撞的整个过程基于这样的假设,即碰撞检测算法中最昂贵的部分是几何图元之间的相交测试。这可能是三角形、射线或任何其他类型的几何图形用于表示物体的表面。对于现代视频游戏来说,模型中所需的细节导致了由成千上万个几何图元组成的网格,并且测试其中的每一个都将是昂贵的。
我们已经在本章前面讨论了我们的方法:我们将使用轴对齐的边界框来近似我们的对象。如果我们在构建人形角色,我们会有一个整体的包围体,然后是一个粗略表示每个身体部分的体。这可能意味着头部有一个球体,四肢的上部和下部有一个胶囊,躯干有一个 AABB。
由于我们的测试非常简单,整个算法适合一个方法。CollisiongManager::TestAgainstBin
列在清单 7-18 中。
清单 7-18。 CollisionManager::TestAgainstBin
void CollisionManager::TestAgainstBin(const unsigned int binIndex, CollisionComponent* pObject)
{
assert(binIndex < m_collisionBins.size());
CollisionBin& bin = m_collisionBins[binIndex];
CollisionComponent* pBinObject = bin.GetFirst();
while (pBinObject)
{
if (pBinObject != pObject &&
pBinObject->Intersects(*pObject))
{
CollisionEventData collisionData;
collisionData.m_pCollider = pBinObject->GetOwner();
SendEventToHandler(
COLLISION_EVENT,
*static_cast<EventHandler*>(pObject),
&collisionData);
}
pBinObject = bin.GetNext();
}
}
我们将想要测试的对象作为参数传递给 bin。我们之前讨论的强力方法会涉及到测试 bin 中的每一个对象;然而,我们知道我们只对测试玩家对象感兴趣,这给了我们一个很好的优化。
该方法简单地遍历 bin 中的每个对象,如果对象不相同,则调用Intersect
方法。当Intersect
方法返回 true 时,我们将COLLISION_EVENT
发送给作为参数传递的CollisionComponent
。
CollisionEventData
结构仅仅持有一个指向我们已经碰撞过的GameObject
的指针。我们在清单 7-19 中查看这个结构。
***清单 7-19。***collision event data 结构
struct CollisionEventData
{
GameObject* m_pCollider;
};
TestAgainstBin
方法突出显示了我们将对事件系统进行的更新。通常在发送事件时,随事件一起传递数据是很方便的。在这种情况下,我们想知道我们碰撞了哪个物体。
为了方便用 e vent,
发送数据,我们添加了一个指向 e vent
类的 void 指针。我们还添加了指向Send
和SendToHandler
方法的空指针。我们在清单 7-20 和 7-21 中这样做。
清单 7-20。 更新事件类
EventHandlerList m_listeners;
EventID m_id;
void* m_pData;
public:
explicit Event(EventID eventId);
∼Event();
void Send(void* pData);
void SendToHandler(EventHandler&
**eventHandler, void* pData);**
`void AttachListener(EventHandler& eventHandler);`
`void DetachListener(EventHandler& eventHandler);`
`清单 7-21。 更新事件::发送和事件::SendToHandler
void Event::Send(void* pData)
{
m_pData = pData;
for (EventHandlerListIterator iter = m_listeners.begin();
iter != m_listeners.end();
++iter)
{
void Event::SendToHandler(EventHandler&
**eventHandler, void* pData)**
`{`
**m_pData = pData;**
`for (EventHandlerListIterator iter = m_listeners.begin();`
`iter != m_listeners.end();`
`++iter)`
`{`
随着这一变化,将 e
vent传递到
EventManager和从
EventManager传递 e
Send和
SendToHandler的每个方法也需要更新,以包含
void`指针参数。
现在我们有了一个检测碰撞的方法和一个告诉一个对象它何时被卷入碰撞的事件,我们可以编写代码来响应这些碰撞。
响应碰撞
我们的游戏设计要求对发生的碰撞做出两种截然不同的反应。我们可以被支撑在箱子的顶部,或者我们可以被箱子的侧面或敌人杀死。
我们的玩家对象的更新代码大部分包含在MovementComponent
中。我们现在的问题是从CollisionComponent
获取对特定碰撞数据感兴趣的物体的COLLISION_EVENT
。
我们将通过转向观察者模式来实现这一点。在这种情况下,我们想要做的是当CollisionEvent
发生时,使用特定的方法通知对象。将使用CollisionListener
接口来实现这一点,如清单 7-22 所示。
清单 7-22。 碰撞监听器接口
class CollisionListener
{
public:
virtual void HandleCollision(CollisionEventData* pData) = 0;
};
在清单 7-23 的中,MovementComponent
继承了这个类。
清单 7-23。 更新运动组件
class MovementComponent
: public Framework::Component
, public Framework::EventHandler
, public Framework::CollisionListener
{
private:
static const unsigned int s_id = 9;
public:
virtual void HandleCollision(Framework::CollisionEventData* pData);
};
清单 7-24 中的方法体处理冲突。
清单 7-24。 运动组件::手柄碰撞
void MovementComponent::HandleCollision(Framework::CollisionEventData* pData)
{
PatrolComponent* pPatrolComponent = component_cast<PatrolComponent>(pData->m_pCollider);
if (pPatrolComponent)
{
// We're colliding with an AI; we're dead!
}
else
{
// We're colliding with a block
TransformComponent* pColliderTransformComponent =
component_cast<TransformComponent>(pData->m_pCollider);
CollisionComponent* pColliderCollisionComponent =
component_cast<CollisionComponent>(pData->m_pCollider);
assert(pColliderTransformComponent && pColliderCollisionComponent);
const Vector3& translation =
pColliderTransformComponent->GetTransform().GetTranslation();
Vector3 minPosition(pColliderCollisionComponent->GetMin());
minPosition.Add(translation);
TransformComponent* pObjectTransformComponent =
component_cast<TransformComponent>(GetOwner());
if (pObjectTransformComponent->GetTransform().GetTranslation().m_x <
minPosition.m_x)
{
// We're dead because we've hit the side of the block
}
else
{
SetIsSupported(
true,
pColliderCollisionComponent->GetMax().m_y + translation.m_y);
}
}
}
正如你在这里看到的,我们通过检查物体是否有PatrolComponent;
来确定我们击中的物体是盒子还是人工智能。
如果是一个盒子,那么我们需要确定我们是碰到了顶部还是侧面。为了做到这一点,我们得到碰撞物体的TransformComponent
和CollisionComponent
。
因为我们将从左向右移动,我们只会死于击中盒子的左侧,所以我们需要检查边界盒的最小位置。最小位置被转换到世界空间并存储在minPosition
向量中。然后我们得到MovementComponent
的所有者的TransformComponent
。如果物体平移的 x 位置在盒子最小左边位置的左边,我们确定我们碰到了盒子的边。如果我们在左边位置的右边,我们已经到达顶部。
如果我们在盒子的顶部,我们调用SetIsSupported
并传递边界框的平移顶部。如清单 7-25 所示。
***清单 7-25。***movement component::SetIsSupported
void MovementComponent::SetIsSupported(bool isSupported, float floor = 0.0f)
{
m_isSupported = isSupported;
m_floor = floor;
}
为了允许玩家坐在盒子上面,我们还必须更新 HandleEvent 方法;我们在清单 7-26 中这样做。
SetIsSupported
方法简单地设置了m_isSupported
字段和m_floor
字段。
清单 7-26。 更新 MovementComponent::HandleEvent
else if (pEvent->GetID() == UPDATE_EVENT)
{
TransformComponent* pTransformComponent = component_cast<TransformComponent>(GetOwner());
assert(pTransformComponent);
CollisionComponent* pCollisionComponent = component_cast<CollisionComponent>(GetOwner());
assert(pCollisionComponent);
if (pTransformComponent && pCollisionComponent)
{
const Vector3& position = pTransformComponent->GetTransform().GetTranslation();
bool falling = m_acceleration.m_y < 0.0f;
Vector3 bvMin = pCollisionComponent->GetMin();
Vector3 translation = m_velocity;
translation.Multiply(Timer::GetSingleton().GetTimeSim());
translation.Add(position);
const float offsetFloor = m_floor – bvMin.m_y;
if (m_isSupported &&falling &&(translation.m_y < offsetFloor))
{
translation.m_y = offsetFloor;
}
pTransformComponent->GetTransform().SetTranslation(translation);
Timer& timer = Timer::GetSingleton();
Vector3 accel = m_acceleration;
accel.Multiply(timer.GetTimeSim());
m_velocity.Add(accel);
static const float GRAVITY_MULTIPLIER = 15.0f;
static const float GRAVITY_CONSTANT = –9.8f;
m_acceleration.m_y += GRAVITY_MULTIPLIER * GRAVITY_CONSTANT * timer.GetTimeSim();
if (falling &&m_isSupported)
{
m_acceleration.m_y = 0.0f;
m_velocity.m_y = 0.0f;
}
}
// Always undo support after an update: we'll be resupported if we are colliding with a block.
SetIsSupported(false);
}
现在我们正在测试冲突,是否支持我们的决定已经为我们做出了。在这种情况下,我们需要得到CollisionComponent
,这样我们就可以确定我们的平移距离对象的底部有多远。我们通过从地板值中减去包围体的最小 y 位置来做到这一点。
然后我们测试我们的位置是否应该固定在地板上。应该是,如果我们被支持,正在下降,我们的平移的 y 场低于我们的offsetFloor
值。
如果我们在下落,并且被支撑在一个表面上,我们的加速度和速度就被清除了。
最后,我们总是希望清除支持的标志。我们的碰撞系统将重新测试,以确定我们是否仍然与我们正在休息的盒子碰撞,如果是,我们将在下一帧再次被设置为受支持。
现在我们知道了MovementComponent
如何处理碰撞。我们还没有处理死亡案例,因为我们还没有搬家。一旦我们让玩家带着摄像机移动,我们将在第八章讲述死亡。我们也不知道CollisionListener
接口是如何使用的。我们需要再看一眼更新后的CollisionComponent
,并在清单 7-27 中这样做。
清单 7-27。 向 CollisionComponent 类添加监听器
class CollisionComponent
: public Component
, public EventHandler
{
private:
static const unsigned int s_id = 2;
Vector3 m_min;
Vector3 m_max;
typedef std::vector<CollisionListener*> CollisionListenerVector;
typedef CollisionListenerVector::iterator CollisionListenerVectorIterator;
CollisionListenerVector m_eventListeners;
public:
static unsigned int GetId() { return s_id; }
explicit CollisionComponent(GameObject* pOwner);
virtual ∼CollisionComponent();
.
.
.
void AddEventListener(CollisionListener* pListener)
{
m_eventListeners.push_back(pListener);
}
};
我们在清单 7-4 中看到了对AddEventListener
的调用,所以我们已经从最初创建我们的对象时回到了原点。管理监听器就像维护一个指向CollisionListener
对象的指针向量一样简单。
CollisionComponent
不关心对碰撞本身的响应;它只需要将COLLISION_EVENT
数据分发给对涉及该对象的碰撞感兴趣的对象。在清单 7-28 中,我们遍历CollisionListener
对象的向量,并调用它们的HandleCollision
方法。
清单 7-28。 碰撞组件::手柄事件
void CollisionComponent::HandleEvent(Event* pEvent)
{
if (pEvent->GetID() == COLLISION_EVENT)
{
CollisionEventData* pCollisionData =
static_cast<CollisionEventData*>(pEvent->GetData());
if (pCollisionData && pCollisionData->m_pCollider)
{
for (CollisionListenerVectorIterator iter = m_eventListeners.begin();
iter != m_eventListeners.end();
++iter)
{
(*iter)->HandleCollision(pCollisionData);
}
}
}
}
既然碰撞处理已经完成,我们需要在每一帧触发系统来检测碰撞。
运行碰撞测试
场景中的对象更新后,最好检测碰撞。为此,我们将注册一个新事件POSTUPDATE_EVENT
。
我们在清单 7-3 中看到,DroidRunnerLevel
类存储了一个指向播放器对象CollisionComponent
的指针。我们还在窄阶段碰撞检测部分看到,我们可以通过只测试玩家对象的碰撞来优化检测算法。这两个事实密切相关。在清单 7-29 中,我们使用存储的指针来测试碰撞。
***清单 7-29。***DroidRunnerLevel::handle events
void DroidRunnerLevel::HandleEvent(Event* pEvent)
{
if (pEvent->GetID() == POSTUPDATE_EVENT && m_pPlayerCollisionComponent)
{
CollisionManager::GetSingleton().TestAgainstBin(0, m_pPlayerCollisionComponent);
}
}
这就是我们运行碰撞检测算法所需的全部代码。
清单 7-30 显示了对章节的更新方法的更新,这是触发POSTUPDATE_EVENT
所必需的。在这里,我们确保应该在更新后完成的任何任务都以正确的顺序完成。
清单 7-30。 触发 POSTUPDATE_EVENT
void Chapter7Task::Update()
{
if (Renderer::GetSingleton().IsInitialized())
{
Framework::SendEvent(Framework::UPDATE_EVENT);
Framework::SendEvent(Framework::POSTUPDATE_EVENT);
Framework::SendEvent(Framework::RENDER_EVENT);
}
}
这就是我们游戏中碰撞检测所需要的。我们还需要对Renderer
做一个改变,你应该知道。
使用 Z 缓冲器
当对象被渲染到帧缓冲区时,默认行为是将每个像素渲染到屏幕上。当一个对象被渲染但应该在先前已经被渲染的对象之后时,问题就出现了。由于像素总是被写入帧缓冲区,我们的第二个对象将覆盖第一个对象,即使我们不希望这样。
我们可以通过使用 z 缓冲区来解决这个问题。z 缓冲区或深度缓冲区存储每个渲染像素写入帧缓冲区时的深度。深度缓冲器中的每个像素最初被设置为 1。变换对象的顶点并为片段着色做好准备后,将计算每个片段的 z 值,其深度介于 0 和 1 之间。如果新片段的深度小于该位置的现有深度,它将在片段着色器中进行处理,并将颜色写入帧缓冲区。如果深度大于现有像素,该片段将被丢弃,而帧和深度缓冲区中的现有值将被保留。
我们通过以下方式在 OpenGL 中启用深度测试。
-
Select an EGL configuration which supports a depth buffer, shown in Listing 7-32.
清单 7-31。 EGL 深度配置属性
const EGLint attribs[] = { EGL_RENDERABLE_TYPE, EGL_OPENGL_ES2_BIT, EGL_SURFACE_TYPE, EGL_WINDOW_BIT, EGL_BLUE_SIZE, 8, EGL_GREEN_SIZE, 8, EGL_RED_SIZE, 8, EGL_DEPTH_SIZE, 24, EGL_NONE };
-
启用深度测试(清单 7-32 )。
-
Clear the depth buffer (Listing 7-32).
清单 7-32。 启用 GL_DEPTH_TEST 并清除深度缓冲
void Renderer::Update() { if (m_initialized) { glEnable(GL_DEPTH_TEST); glClearColor(0.95f, 0.95f, 0.95f, 1); glClear(GL_COLOR_BUFFER_BIT| GL_DEPTH_BUFFER_BIT);
清单 7-31 和 7-32 中修改过的三行是在我们的游戏代码中启用深度测试所需要的。从现在开始,我们可以确定场景中的所有像素都是那些被栅格化为离相机最近的物体的一部分的像素。
摘要
这一章是关于建立游戏关卡和碰撞检测系统的旋风介绍。我们所看到的是,围绕游戏关卡的挑战很大程度上是那些涉及创建对象来填充关卡的挑战。在我们的游戏中,我们使用了老式 2D 游戏中屡试不爽的平铺方法,用同样大小的方块构建了一个 3D 关卡。
学习游戏编程时使用这样一个系统的主要好处是它非常容易通过计算来创建。现代游戏更倾向于使用离线工具来构建关卡,这些关卡可以在运行时加载前进行预处理。然而,这些系统所面临的许多问题与我们在本章中看到的以及我们在前一章中开始解决的问题是一样的,即如何在对象之间进行通信以及如何管理大量的对象。当构建初始级别时,我们的组件和事件系统使其中一些任务变得微不足道。
一旦我们有了等级数据,我们就开始计算物体之间的碰撞。我们了解了为什么物体的宽相位过滤对于减少我们需要测试的对的数量是重要的,以及为什么窄相位有助于减少碰撞测试本身的计算复杂性。我们已经将这些经验应用到我们的游戏中,创建了一个宁滨系统,允许我们以游戏代码选择的任何方式将对象存储在一起,并且我们已经为我们的游戏实现了最佳的测试算法,其中我们只关心单个对象上的碰撞效果。
为了结束这一章,我们稍微绕了一下图形编程,看看如何启用深度测试,以确保渲染到帧缓冲区的像素实际上是我们希望看到的像素。
在下一章中,我们将显著扩展我们的级别,并更好地利用碰撞管理器中的离散箱。我们还将看看如何创建一个游戏摄像头,让玩家在关卡中移动,并编写代码来决定玩家死后该做什么。我们还将看看如何使用相机对象来减少我们将发送到 GPU 的几何量。``