游戏编程模式-脏标记模式
“将工作推迟到必要时进行以避免不必要的工作。“
动机
许多游戏都有一个场景图的东西。这是一个庞大的数据结构,包含了游戏世界中所有的物体。渲染引擎使用它来决定物体绘制到屏幕上的什么地方。通常来说,游戏中的物体都含有一个形状或者说模型,和一个”变换“。这个变换是一个包含物体位置、旋转角度和物体大小的一个数据结构,我们如果想该变物体的位置或大小,通过改变这个变换就可以了。
而通常在游戏中,场景图是分层的,也就是说场景中的物体会绑定在一个父物体上。在这种情况下,物体在屏幕中的位置就不止于它自己的变换有关,还与它的父物体的变化有关。想一想,当一个游戏角色骑着一个马的时候,角色可以看成是马的一个子物体,角色最终的位置要由角色在马上的位置和马的位置共同计算出来。也就是说,角色的位置需要马的位置变化+角色在马上的局部变化,如下图:
在游戏中,变换我们通常使用矩阵表示,如果物体都是静止的,那么我们直接存储每个物体的世界变换即可,每个物体只需要计算一次。但在游戏中,这通常都不可能。现代游戏中都有大量的移动物体,对于移动的物体,我们改变的它的变换,同时也会影响它子物体的世界变换,那么一个简单的方法就是每一帧都重新计算物体的世界变换,很明显这对于不移动的物体,将产生大量的重复计算,这对CPU资源是一种可怕的浪费。
对于这种情况,我们可以把物体的世界变换缓存起来,如果物体不变换,那么我们就使用这个缓存的变换即可。但还有另一种情况没有处理,假设我们其在马背上的角色手上还有一支枪,但我们把马、角色、枪都移动一下的时候,我们分析一下,对于马,需要一次变换(马的局部变换x马的变换),对于马背上的角色,我们需要做两次变化(马的局部变换x马的变换x角色的局部变化x角色的变换),而对于马背上的枪,我们需要计算三次(马的局部变换x马的变换x角色的局部变换x角色的变换x枪的局部变化x枪的变换),也就是说移动了3个物体,但做了6次变换计算,也就是说里面有3次是无效的计算,而这些无效计算会随着层数增长会急剧增加,比如4层子物体,就会有6次重复计算,5层就有10次重复计算,现代游戏中,一个物体由5、6层的子物体很常见,这样也会浪费大量的CPU计算时间。
对于这个问题,我们可以通过将修改局部变换和更新世界变换解耦来解决这个问题。这让我们在单次渲染中修改多个局部变换,然后在所有变动完成之后,在实际渲染器使用之前仅需要计算一次世界变换。要做到这一点,需要给每个物体添加一个“flag”的标记,这个flag有两个状态,一个为“true”,一个为“false”,有时也叫“set”和“cleared”。当需要一个物体的世界变换时,就来检查这个flag标志,如果是“set”状态,表示这个物体的世界变换需要重新计算,重新计算后,将flag重置为“cleared”,也就是说这个标记表示世界变换是不是过期了。由于某些原因,传统上这个“过期的”被称作是“脏的”。也就是”脏标记“,”Dirty bit“也是这个模式的常见名字。
如果使用这种方法,我们看一下上面的移动会如何计算。对于马,计算一次,标记为”cleared“,而对于马背上的角色,因为马的标记为”cleared“,所以直接取马缓存的世界变换即可,这样需要计算一次,同理,枪也只需要计算一次。也就是说,移动了3个物体,我们执行了3次变换计算,这应该是你能期望的最好的方法。每个被影响的物体只需要计算一次。
只需要简单的一个位数据,这个模式为我们做了不少事:
它将父链上物体的多个局部变换改动分解为每个物体的一次计算;
它避免了没有移动物体的重复计算;
一个额外的好处:如果一个物体在渲染之前移除了,那就根本不用计算它的世界变换。
脏标记模式
以组原始数据随时间变化。一组衍生数据ing过一些代价昂贵的操作由这些数据确定。一个脏标记跟踪这个衍生数据是否和原始数据同步。它在原始数据改变时被设置。如果它被设置了,那么当需要衍生数据时,它们就会被重新计算并且标记被清除。否则就使用缓存的数据。
使用情境
相对于其他模式,这个模式解决一个相当特定达的问题。同时,就想大多数优化那样,仅当性能问题严重到值得增加代码复杂度时才使用它。脏标记涉及到两个关键词:“计算”和“同步”。在这两种情况下,处理原始数据到衍生数据的过程在事件或其他方面由很大的开销。在我们的场景图例子中,过程很慢时因为计算量大。相反,当使用这个模式做同步时,派生数据通常在别的地方——也许磁盘上,也许在网络上的其他机器上——光是简单把它从A移动到B就很费力。
这里也有些其他的需求:
原始数据的修改次数比衍生数据的使用次数多。衍生数据在使用之前会被接下来的原始数据改动而失效,这个模式通过避免处理这些操作来运作。如果你在每次改动原始数据时都立刻需要衍生数据,那么这个模式就没有效果。
递增的更新数据十分的困难。我们假设游戏的小船能运载众多的战利品。我们需要知道所有的总重量。我们能够使用这个模式,为总量设置一个脏标记。每当我们增加或者减少战利品时,我们设置这个标记。当我们需要总量的时候,我们将所有的战利品重量相当同时移除标记。这里一个更简单的方法时维持一个动态的总重量,在每次增加或减少战利品时直接计算。这样保持衍生数据更新时,这种方法要比使用这个模式更好。
这些要求听起来让人觉得脏标记很少由合适使用的时候,但是你总能发现它由能帮上忙的地方。通常你在代码中搜索“Dirty”一词,就能发现这个模式的应用之处。
使用须知
即使当你由相当的自信认为这个模式十分适用,这里还有一些小的瑕疵会让你感到不便。
延迟太长会有代价
这个模式把耗时的工作推迟到需要它的时候才进行,而到需要时,往往刻不容缓。而我们使用这个模式的原因时计算出结果的过程很慢。在上面的例子中,矩阵的计算还好,能在一帧之内完成,如果工作量大到一个人能察觉的时间,在游戏中就可能引发一个不友好的视觉卡顿。另一个问题就是如果某个东西出错,你可能完全无法工作。当你将状态保存在一个更加持久化的形式中时,使用这个模式,问题会尤其突出。
举个例子,文本编辑器知道是否还有“未保存的修改”。在你文档的标题栏上会有一个小星星或子弹表示这个脏标记,原始数据是在内存中的打开文档,衍生数据是磁盘上的文件。许多程序都仅在文档关闭或程序退出时才会自动保存。这在大部分的情况下都运行良好,但如果你不小心将电源线踢出,那么你的工作就付之东流了。编辑器为了减缓这种损失会在后台自动保存一个备份。自动保存备份的频率会在不会丢失太多数据,也不会造成文件系统繁忙之间去一个折中点。
必须保证每次状态改动时都设置脏标记
衍生数据时通过原始数据计算而来的,也就是说衍生数据时原始数据的一份缓存。这里通常会发生一个棘手的问题,即使缓存无效——也就是缓存数据和原始数据不同,那么接下来我们的计算就都不正确了。也就是说我们必须保持在任何地方改动原始数据时都要设置脏标记。一个解决方案就是把原始数据修改和设置脏标记封装起来。任何改动都通过一个API入口来修改,这样就不用担心由什么遗漏。
必须在内存中保存上次的衍生数据
使用脏标记模式的时候,我们会缓存一份衍生数据,当脏标记没有设置的时候,我们就直接使用这个缓存数据。而当你没有使用这个模式时,每次需要衍生数据时都重新计算一次,然后丢弃,这样避免内存的开销。但代价就是每次都要计算。这是一个时间和空间的权衡。但内存比较便宜而计算费时的情况下时合算的,而当内存比时间更加宝贵的时候,在需要时计算会比较好。
示例代码
我们先实现一个矩阵计算的类:
class Transform
{
public:
static Transform origin();
Transform combine(Transform& other);
};
上面的代码提供了一个combine的操作用于组合其他的变换,这样我们就可以通过组合父链中的局部变换来获得它的世界变换。orgin方法用于获取“原始”的世界变换——这个变换没有位移、旋转、缩放。然后我们再定义一个简单的物体类,它渲染的时候使用物体的世界变换。
class GraphNode
{
public:
GraphNode(Mesh* mesh)
:mesh_(mesh),
local_(Transform::origin())
{}
void render(Transform parent)
{
Transform world = local_.combine(parent);
if(mesh_) renderMesh(mesh_,world);
for(int i=0;i<numChildren_;++i)
{
children_[i]->render(world);
}
}
void RenderMesh(Mesh* mesh,Transform& transform)
{
//render code
}
private:
Transform local_;
Mesh* mesh_;
static const int MAX_CHILDREN=100;
GraphNode* children_[MAX_CHILDREN];
int numChildren_;
};
这里GraphNode表示场景图中的节点,每个节点表示一个物体,物体中包含一个局部变换,一个mesh(也就是形状),还有其子物体集合。render接收一个父物体的世界变换参数,然后结合自身的局部变换得到本物体的世界变换,从而进行绘制。这里我们的场景图按树的结构组织,游戏开始时可以构建一个不需要绘制的物体作为根节点:
GraphNode* graph = new GraphNode(NULL);
然后遍历则个场景树绘制每个物体即可。注意,在GraphNode的render函数中,我们是递归调用的,所以绘制整个场景物体的方法其实只有一行代码:
graph->render(Transform::origin());
这里,我们是先绘制的父物体,再绘制子物体,所以在子物体的绘制过程中不需要再去计算父物体的世界变换,因为在父物体中已经计算过了。
让我们“脏起来”
上面的代码做了正确的事——在正确的地方渲染图元——但并不高效。它每帧都在每个“node”上调用“local_.combine(parentWorld)"。我们看看脏标记模式如何修正这点。首先我们需要添加两个函数到”GraphNode“中:
class GraphNode
{
public:
GraphNode(Mesh* mesh)
:mesh_(mesh),
local_(Transform::origin()),
dirty_(true)
{}
void render(Transform parent)
{
Transform world = local_.combine(parent);
if(mesh_) renderMesh(mesh_,world);
for(int i=0;i<numChildren_;++i)
{
children_[i]->render(world);
}
}
void RenderMesh(Mesh* mesh,Transform& transform)
{
//render code
}
void setTransform(Transform local);
private:
Transform local_;
Mesh* mesh_;
static const int MAX_CHILDREN=100;
GraphNode* children_[MAX_CHILDREN];
int numChildren_;
bool dirty_;
Transform world_;
};
"world_"缓存了上次计算了的世界变换,“dirty_"就是脏标记。这里注意的是”dirty_"标记一开始就是标记为true,也就是说当我们创建一个新的节点时,我们没有计算过它的世界变换。在开始,它就没有和局部变换同步。
我们需要这个模式的唯一理由就是物体能够移动,所以我们来提供这个功能:
void GraphNode::setTransform(Transform local)
{
local_ = local;
dirty_ = true;
}
简单的给局部变换赋值通知设置dirty_为true。但在这里好像忘了点什么?对了,子节点。在这里我们并没有设置子节点的脏标记。我们可以在这里使用递归来设置子节点的脏标记,但这比较缓慢。相反我们可以在渲染时做一些聪明的事。来看:
void GraphNode::render(Transform parentWorld,bool dirty)
{
dirty |= dirty_;
if(dirty)
{
world_ = local_.combine(parentWorld);
dirty_ = false;
}
if(mesh_) renderMesh(mesh_,world_);
for(int i=0;i<numChildren_;++i)
{
children_[i]->render(world_,dirty);
}
}
在这里,我们在渲染之前判断脏标记,如果为“true”则重新计算世界变换,同时清除脏标记,否则直接使用之前缓存的世界变换。这里有个很聪明的技巧就是使用一个另一个dirty变量,这个变量与物体本身的脏标记进行或预算,然后再传递给子物体渲染,这样,只要父链中有一个节点设置的脏标记,则dirty就为true,然后再传递给子节点渲染,也就是说,只要父链中设置了脏标记,那么子节点就会重新计算世界变换。使用这个方法能避免我们在“setTransform”中递归的去改变子节点的脏标记。最终的结果就是我们想要的:修改一个节点的局部变换只是需要几条赋值语句。渲染世界时只计算了自上一帧以来最少的变动的世界变换。
设计决策
这个模式是相当特定的,所以只需要注意几点:
何时清除脏标记
当需要计算结果时
当计算结果从不使用时 ,它完全避免了计算。当原始树变动的频率远大于衍生数据访问的频率时,优化效果显著;
如果计算记过十分耗时,会造成明显的卡顿。把计算工作推迟到玩家需要查看结果时才做会影响游戏体验。这在计算足够快的情况下没什么问题,但是一旦计算十分耗时,则最好提前开始运算。
在精心设定的检查点
有时,在游戏过程中有一个时间点十分适合做延时工作。举个例子,我们可能只想在船靠岸时才存档。或则存档点就是游戏机制的一部分。我们可能在一个加载界面或者一个切图下做这些工作。
这些工作并不影响用户体验。不同于之前的选项,当游戏忙于处理时你可以通过其他东西分散玩家的注意力。
当工作执行时,你失去了控制权。这和之前一点有些相反。在处理时,你能轻微的控制,保证游戏优雅的处理。
你“不能确保”玩家真正到达检查点,或者到达任何你设定的标准。如果它们迷失了或者游戏进入了奇怪的状态,你可以将预期的操作进一步延迟。
在后台
通常你可以在最初变动时启动一个计时器,并在计时器到达时处理之间的所有变动。
你可以调整工作执行的频率。通过调整计时器的间隔,你可以按照你想要的频率进行处理;
你可以做更多冗余工作。如果在定时器期间原始状态的改动比较少,那么最终可以处理大部分没有修改的数据;
需要支持异步操作。在后台处理数据意味着玩家可以同时做其他的事情。这意味着你需要线程或者其他并发支持,以便能够在游戏进行时处理数据。
因为玩家可能同时与你正在处理的原始数据交互,所以也要考虑并行修改数据的安全性。
脏标记追踪的粒度多大
想象一个我们海盗游戏允许玩家建造和定制它们的海盗船。船会自动线上保存以便玩家离线之后恢复。我们使用脏标记来决定船的哪些甲板被改动了并需要发送到服务器。每一份发送给服务器的数据包含一些船的改动数据和一份元数据,该元数据描述这份改动是在什么地方发生的。
更细的粒度
你将甲板上的每一份小木块加上脏标记。
你只需要处理真正变动了的数据,你将船的真正变动的木块数据发送给服务器;
更粗糙的粒度
另外我们可以为每一个甲板关联一个脏标记。在它之上的每份改动将这个甲板标记为脏。
你最终需要处理未变动的数据。当你在甲板上放一个酒桶时,你需要把整个甲板的数据发送给服务器;
存储脏标记消耗更少的内存。添加10个酒桶在甲板上只需要一个位来跟踪它们;
固定开销花费的时间更少。当处理修改后的树时,通常有一套固定的流程要预先处理这些数据。在这个例子中,就是表示船上哪些是改动了的数据。处理块越大,处理快就越少,也意味着通用开销越少。