zoukankan      html  css  js  c++  java
  • 游戏编程模式-脏标记模式

      “将工作推迟到必要时进行以避免不必要的工作。“

    动机

      许多游戏都有一个场景图的东西。这是一个庞大的数据结构,包含了游戏世界中所有的物体。渲染引擎使用它来决定物体绘制到屏幕上的什么地方。通常来说,游戏中的物体都含有一个形状或者说模型,和一个”变换“。这个变换是一个包含物体位置、旋转角度和物体大小的一个数据结构,我们如果想该变物体的位置或大小,通过改变这个变换就可以了。

      而通常在游戏中,场景图是分层的,也就是说场景中的物体会绑定在一个父物体上。在这种情况下,物体在屏幕中的位置就不止于它自己的变换有关,还与它的父物体的变化有关。想一想,当一个游戏角色骑着一个马的时候,角色可以看成是马的一个子物体,角色最终的位置要由角色在马上的位置和马的位置共同计算出来。也就是说,角色的位置需要马的位置变化+角色在马上的局部变化,如下图:

      在游戏中,变换我们通常使用矩阵表示,如果物体都是静止的,那么我们直接存储每个物体的世界变换即可,每个物体只需要计算一次。但在游戏中,这通常都不可能。现代游戏中都有大量的移动物体,对于移动的物体,我们改变的它的变换,同时也会影响它子物体的世界变换,那么一个简单的方法就是每一帧都重新计算物体的世界变换,很明显这对于不移动的物体,将产生大量的重复计算,这对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次变换计算,这应该是你能期望的最好的方法。每个被影响的物体只需要计算一次。

      只需要简单的一个位数据,这个模式为我们做了不少事:

    •   它将父链上物体的多个局部变换改动分解为每个物体的一次计算;
    •   它避免了没有移动物体的重复计算;
    •   一个额外的好处:如果一个物体在渲染之前移除了,那就根本不用计算它的世界变换。

    脏标记模式

      一组原始数据随时间变化。一组衍生数据经过一些代价昂贵的操作由这些数据确定。一个脏标记跟踪这个衍生数据是否和原始数据同步。它在原始数据改变时被设置。如果它被设置了,那么当需要衍生数据时,它们就会被重新计算并且标记被清除。否则就使用缓存的数据。

    使用情境

      相对于其他模式,这个模式解决一个相当特定的问题。同时,就想大多数优化那样,仅当性能问题严重到值得增加代码复杂度时才使用它。脏标记涉及到两个关键词:“计算”和“同步”。在这两种情况下,处理原始数据到衍生数据的过程在事件或其他方面由很大的开销。

    •   一种是在我们的场景图例子中,过程很慢是因为计算量大;
    •   另一种情况是派生数据通常在别的地方——也许磁盘上,也许在网络上的其他机器上——光是简单把它从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个酒桶在甲板上只需要一个位来跟踪它们;
    •   固定开销花费的时间更少。当处理修改后的树时,通常有一套固定的流程要预先处理这些数据。在这个例子中,就是表示船上哪些是改动了的数据。处理块越大,处理快就越少,也意味着通用开销越少。
  • 相关阅读:
    out/host/linuxx86/obj/EXECUTABLES/aapt_intermediates/aapt 64 32 操作系统
    linux 查看路由器 电脑主机 端口号 占用
    linux proc进程 pid stat statm status id 目录 解析 内存使用
    linux vim 设置大全详解
    ubuntu subclipse svn no libsvnjavahl1 in java.library.path no svnjavahl1 in java.library.path no s
    win7 安装 ubuntu 双系统 详解 easybcd 工具 不能进入 ubuntu 界面
    Atitit.json xml 序列化循环引用解决方案json
    Atitit.编程语言and 自然语言的比较and 编程语言未来的发展
    Atitit.跨语言  文件夹与文件的io操作集合  草案
    Atitit.atijson 类库的新特性设计与实现 v3 q31
  • 原文地址:https://www.cnblogs.com/xin-lover/p/11973837.html
Copyright © 2011-2022 走看看