理论要点
什么是双缓冲模式:用序列的操作模拟瞬间或者同时发生的事情。通俗地讲就是一个后台缓冲来接受数据,当填充完整后交换给前台缓冲,这样就保证了前台缓冲里的数据都是完整的。
要点:
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];
};
到此,双缓冲模式就讲完了~
双缓冲模式是那种你需要它时自然会想起来的模式。一般是出现在某些数据的访问和计算机执行顺序相关,不同步访问而出现异常的情况。这时就应该自然想到双缓冲,一个后台缓冲写入新数据,一个前台缓冲通过交换后台数据更新表现。