序列模式--双缓冲模式

理论要点

  • 什么是双缓冲模式:用序列的操作模拟瞬间或者同时发生的事情。通俗地讲就是一个后台缓冲来接受数据,当填充完整后交换给前台缓冲,这样就保证了前台缓冲里的数据都是完整的。

  • 要点:
    1,一个双缓冲类封装了一个缓冲:一段可改变的状态。这个缓冲被增量的修改,但我们想要外部的代码将其视为单一的元素修改。 为了实现这点,双缓冲类需保存两个缓冲的实例:下一缓存和当前缓存。

    2,当信息从缓冲区中读取,我们总是去读取当前的缓冲区。当信息需要写到缓存,我们总是在下一缓冲区上操作。 当改变完成后,一个交换操作会立刻将当前缓冲区和下一缓冲区交换, 这样新缓冲区就是公共可见的了。旧的缓冲区则成为了下一个重用的缓冲区。

    3,双缓冲模式两点警示:一,交换本身需要时间,我们一般是通过指针操作,如果交换消耗的时间过长,那么就毫无裨益了。二,我们得保存两个缓冲区,在内存受限的设备上,你可能要付出惨痛的代价。 如果你不能接受使用两份内存,你需要使用别的方法保证状态在修改时不会被请求。

    4,双缓冲模式常用来做帧缓冲区交换。我们几乎可以在任何一个图形API中找到双缓冲模式的应用。如OpenGl中的 swapBuffers() 函数, Direct3D中的“swap chains”,微软XNA框架的 endDraw() 方法。

  • 使用场合:
    一般使用情形是在类似同一块内存数据多个对象同时访问的情况。最常用在帧缓冲区交换。大多数主机和电脑上,显卡驱动提供了这种底层的图形系统支持,我们实际中基本不需要关心,这里讲讲原理帮助我们理解,具体看下面代码分析。

代码分析

1,上面理论部分提到了双缓冲模式主要用在帧缓冲区交换,为什么我们图形渲染需要这样的工作模式呢?下面我们就先来分析下计算机图形系统是如何工作的:
首先需要介绍的是帧缓冲,可以这么理解,它是个二维数组,对应着我们看到的窗口,保存着整张屏幕像素数据,GPU就是从这里面取数据渲染的。想想这么个流程:写入数据—>帧缓冲—>读取数据渲染。写入和读取都是序列操作,一般是从屏幕左上角到右下角依次填充渲染的。这里写入和读取两个行为是异步进行的,这样会出现什么问题呢?就是写入和读取很可能不同步,有可能读取到还没来得及写入的数据,这样就是导致我们看到的屏幕画面撕裂,抖动,bug暴露无遗。这有点类似我们多线程中常见问题了,同一块内存数据多个对象同时访问问题。
好,现在情形我们讲完了,那么具体怎么用双缓冲解决上面问题呢?其实原理很简单:我们用两个缓冲区,一个前台缓冲,一个后台缓冲。写入操作往后台缓冲写入数据,当整个屏幕数据全部写完,然后再让其一次性交换到前台缓冲进行渲染显示。这样我们就不会再出现撕裂、抖动情况了,看到的永远都是完整的整屏图像。

理论讲完了,下面我们试着简单地实现下双缓冲模式,首先是缓冲区本身:

class Framebuffer
{
public:
  Framebuffer() { clear(); }

  /*整个缓冲区默认清理成白色*/
  void clear()
  {
    for (int i = 0; i < WIDTH * HEIGHT; i++)
    {
      pixels_[i] = WHITE;
    }
  }

  /*指定位置的像素颜色值进行设置*/
  void draw(int x, int y)
  {
    pixels_[(WIDTH * y) + x] = BLACK;
  }

  /*用于暴露给外部访问缓冲区持有的整个原始像素数组*/
  const char* getPixels()
  {
    return pixels_;
  }

private:
  /*屏幕宽高*/
  static const int WIDTH = 160;    
  static const int HEIGHT = 120;

  char pixels_[WIDTH * HEIGHT];
};

然后就是场景渲染中双缓冲使用了:

class Scene
{
public:
  Scene()
  : current_(&buffers_[0]),
    next_(&buffers_[1])
  {}

  void draw()
  {
    //将数据写入到后台缓冲区
    next_->clear();

    next_->draw(1, 1);
    // ...
    next_->draw(4, 3);

    swap();
  }

  Framebuffer& getBuffer() { return *current_; }

private:
  void swap()
  {
    //将后台缓冲数据交换到前台
    Framebuffer* temp = current_;
    current_ = next_;
    next_ = temp;
  }

  Framebuffer  buffers_[2];   //双缓冲
  Framebuffer* current_;      //前台缓冲
  Framebuffer* next_;         //后台缓冲
};

这样我们再也不会有图形撕裂和不美观的问题了。

2,上面示例讲的是图形渲染上面的应用,我们再来看一个它在其它领域的使用:
首先,假设遇到过年,我们游戏为了渲染氛围,需要在主城中添加很多NPC,它们之间可以做很多交互AI,如:打闹(这里假设是互相扇对方巴掌)。下面是我们基础的角色基类:

class Actor
{
public:
  Actor() : slapped_(false) {}

  virtual ~Actor() {}
  virtual void update() = 0;                //每帧调用更新自身行为

  void reset()      { slapped_ = false; }   //重置扇巴掌状态
  void slap()       { slapped_ = true; }    //被扇巴掌中
  bool wasSlapped() { return slapped_; }    //是否被扇巴掌

private:
  bool slapped_;   //是否已经被扇巴掌
};

角色的交互舞台,我们通过以下代码构建它:

class Stage
{
public:
  //添加角色
  void add(Actor* actor, int index)
  {
    actors_[index] = actor;
  }

  //更新所有角色,但从内部看,一个时刻仅有一个角色被更新
  void update()
  {
    for (int i = 0; i < NUM_ACTORS; i++)
    {
      actors_[i]->update();
      actors_[i]->reset();
    }
  }

private:
  static const int NUM_ACTORS = 3;

  Actor* actors_[NUM_ACTORS];
};

好,接下来我们实现个具体的角色子类。他假设有这么耿直,面朝一个指定角色,不论谁给了他一巴掌,他就冲着他所面对的角色扇巴掌。

class Comedian : public Actor
{
public:
  //面朝的角色
  void face(Actor* actor) { facing_ = actor; }

  //只要检查到自己被扇,马上扇对面角色一巴掌
  virtual void update()
  {
    if (wasSlapped()) facing_->slap();
  }

private:
  Actor* facing_;   //面朝的角色对象
};

现在,我们初始化场景,往舞台中多放几个这样的角色,使他们每个都面对着下一个,而最后一个面向第一个,形成一个圈,来进行互相扇巴掌游戏~

Stage stage;

Comedian* actor_1 = new Comedian();
Comedian* actor_2 = new Comedian();
Comedian* actor_3 = new Comedian();

actor_1->face(actor_2);
actor_2->face(actor_3);
actor_3->face(actor_1);

stage.add(actor_1, 0);
stage.add(actor_2, 1);
stage.add(actor_3, 2);

好,现在我们让actor_1来开启游戏序幕:

actor_1->slap();   //被扇开始
stage.update();    //更新所有角色,开启互扇模式

让我们分析下代码的执行流程,stage中的update是序列更新每个角色对象,也就是这么个顺序:actor_1被扇—>actor_1扇actor_2—>actor_2扇actor_3—>actor_3扇actor_1—>end。

我们不是要讲双缓冲的另一个应用么?貌似目前讲地和这个还没半毛线关系~~既然是这样,肯定上面示例写法是个反例啥,套路都是用来带入问题的。好,既然这样上面的示例代码有什么问题呢?
我们试着修改下角色添加进舞台的顺序:

stage.add(actor_1, 2);
stage.add(actor_2, 1);
stage.add(actor_3, 0);

如果这样,再来分析下代码的执行流程:actor_1被扇—>actor_3无反应—>actor_2无反应—>actor_1扇actor_2—>end。
哦不!完全不一样了。添加角色到舞台的顺序影响了我们游戏的进行。这该怎么办了?现在就是我们使用双缓冲的时候了~
首先我们把每个角色的“被扇巴掌”状态缓存起来:

class Actor
{
public:
  Actor() : currentSlapped_(false) {}

  virtual ~Actor() {}
  virtual void update() = 0;

  void swap()
  {
    // Swap the buffer.
    currentSlapped_ = nextSlapped_;

    // Clear the new "next" buffer.
    nextSlapped_ = false;
  }

  void slap()       { nextSlapped_ = true; }
  bool wasSlapped() { return currentSlapped_; }

private:
  bool currentSlapped_;
  bool nextSlapped_;
};

现在每个角色有两个是否被扇状态字段,当前状态用于读取,下一个状态用于写入。这里还有Stage类中也得做下小修改:

void Stage::update()
{
  for (int i = 0; i < NUM_ACTORS; i++)
  {
    actors_[i]->update();
  }

  for (int i = 0; i < NUM_ACTORS; i++)
  {
    actors_[i]->swap();
  }
}

现在我们再来分析下双缓冲状态下代码的执行流程:actor_1被扇—>actor_3无反应—>actor_2无反应—>actor_1扇actor_2—>actor_3前后缓冲交换无变化—>actor_2交换变为被扇状态—>actor_1交换变为重置状态—>然后update到第二帧开始actor_2扇actor_3,再到下一帧actor_3扇actor_1,如此依次循环。
每个角色在其被扇巴掌的那一帧中仅会看到一个巴掌,这样一来,交互的动作就不会再受添加到舞台的顺序影响了。对于用户和外部代码而言,这些角色在一帧中就是同步更新的。

嗯,另一种双缓冲应用算是讲完了,最后我们来分析下上面的实现方式,看看能不能再优化一波,它是对每个角色都保存着两个状态字段,为了交换,我们需要遍历整个对象集合,通知每个对象交换。而且我们这个互扇巴掌的游戏其实只与相邻的两个人相关,我们不需要把所有的角色状态都缓冲下来。
既然是这样,我们可以这样分析,首先,只与相邻两个角色相关,我们只需保存一个只有两个元素的数组状态字段就行了。然后,不需要所有角色都保存各自缓冲,这就可以用静态字段实现共享。
下面是优化后的实现参考:

class Actor
{
public:
  static void init() { current_ = 0; }
  static void swap() { current_ = next(); }

  void slap()        { slapped_[next()] = true; }
  bool wasSlapped()  { return slapped_[current_]; }

private:
  static int current_;
  static int next()  { return 1 - current_; }

  bool slapped_[2];
};

到此,双缓冲模式就讲完了~
双缓冲模式是那种你需要它时自然会想起来的模式。一般是出现在某些数据的访问和计算机执行顺序相关,不同步访问而出现异常的情况。这时就应该自然想到双缓冲,一个后台缓冲写入新数据,一个前台缓冲通过交换后台数据更新表现。

  • 2
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值