动机
计算机显示器的显示设备在每一时刻仅绘制一个像素。显示设备从左至右地扫描屏幕每行中的像素,并如此从上至下地扫描屏幕上的每一行。当它扫描至屏幕的右下角时,它将重定位至屏幕的左上角并如前述那样地重复扫描屏幕。这一扫描过程如此地快速(大概每秒60次),以至于我们的眼睛无法察觉这一过程。对于我们而言,扫描的结果就是屏幕上一块彩色像素组成的静态区域,即一张图片。
在多数计算机中,它从帧缓冲区(framebuffer)中获知哪些像素渲染在什么地方这些信息。
双缓冲主要是为了解决渲染速度超过写入速度并访问了帧缓冲中哪些未写入的部分,造成了撕裂的BUG。我们的程序一次只渲染一个像素,同时我们要求显示器一次性显示所有的像素。
前面的帧缓存用于展示,后面的帧缓存用于写入数据准备下一帧的渲染。
模式
定义一个帧缓冲区类来封装一个缓冲区:一块能被修改的状态区域。这块缓冲区能被逐步地修改,但我们希望任何外部的代码将对该缓冲区的修改都视为原子操作。为实现这一点,此类中维护两个缓冲区实例:后台缓冲区和当前缓冲区。
当要从缓冲区读取信息时,总是从当前缓冲区读取。当要往缓冲区中写入数据时,则总在后台缓冲区上进行。改动完成后,则执行“交换”操作来将当前缓冲区与后台缓冲区进行瞬时的交换。同时刚被换下来的当前缓冲区则成为现在的后台缓冲区以供复用。
使用情境
下面条件都成立时,适用双缓冲模式:
- 我们需要维护一些被逐步改变着的状态量
- 同个状态可能会在其被修改的同时被访问到。
- 我们希望避免访问状态的代码能看到具体的工作过程。
- 我们希望能够读取状态但不希望等待写入操作的完成。
注意事项
交换本身需要时间
双缓冲模式需要在状态写入完成后进行一次交换操作,操作必须是原子性的。
我们必须有两份缓冲区
这个模式另外一个后果就是增加了内存使用。
示例代码
首先是缓冲区本身:1
2
3
4
5
6
7
8
9
10class Framebuffer
{
public:
// Constructor and methods...
private:
static const int WIDTH = 160;
static const int HEIGHT = 120;
char pixels_[WIDTH*HEIGHT];
};
缓冲区拥有一些基本操作:将整个缓冲区清理为默认颜色,对指定位置的像素颜色值进行设置。1
2
3
4
5
6
7
8
9
10
11
12void Framebuffer::clear()
{
for(int i = 0; i < WIDTH * HEIGHT; i++)
{
pixels_[i] = WHITE;
}
}
void Framebuffer::draw(int x, int y)
{
pixels_[(WIDTH * y) +x] = BLACK;
}
还包含了getPixels()函数,用于暴露给外部以访问缓冲区持有的整个原始像素数组:1
2
3
4const char* Framebuffer::getPixels()
{
return pixels_;
}
显卡驱动可以在任何时刻对缓冲区调用getPixels(),对用户来说,部分的图像还在,但是这一帧的其他图像丢失了,下一帧可能又是其他部分的图像受到干扰,结果就是可怕的频闪图像。我们可以用双缓冲来修正它:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25class 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()
{
// Just switch the pointers.
Framebuffer* temp = current_;
current_ = next_;
next_ = temp;
}
Framebuffer buffers_[2];
Framebuffer* current_;
Framebuffer* next_;
};
当下一次显卡调用getBuffer()函数时,它将获取到我们刚刚完成绘制的那块新的缓冲区,并将其内容绘制到屏幕上。
并非只针对图形
我们已经通过上述图形示例描述了第一种情况——状态直接被另一个线程或中断的代码所直接访问。
而另一种情况同样很常见:进行状态修改的代码访问到了其正在修改的那个状态。这会在很多地方发生:尤其是实体的AI和物理部分,在它与其他实体进行交互时会发生这样的情况,双缓冲模式往往在此情形下奏效。
缓冲区如何交换
为达到最优性能,我们希望这个过程越快越好。
交换缓冲区指针或者引用
- 这很快。无论缓冲区多大,交换的只是一对指针的赋值。
- 外部代码无法存储指向某块缓冲区的持久化指针。这是该方法主要的约束。
- 缓冲区中现存的数据会来自两帧之前而不是上一帧。
在两个缓冲区之间进行数据的拷贝
假如我们无法对缓冲区进行指针重定向,那么唯一的办法就是将数据从后台缓冲区实实在在地拷贝到当前缓冲区。对于简单的数据结构(比如布尔值)而言,这不会花费比复制指向缓冲区的指针更多的时间。
- 位于后台缓冲区里的数据与当前的数据就只差一帧时间。
- 交换操作可能会花去更多的时间。
缓冲区的粒度如何
- 假如缓冲区是单个整体
交换操作很简单,因为全局只有一对缓冲区,只需要进行一次交换操作。 - 假如许多对象都持有一块数据
交换较慢。为实现交换,我们需要遍历对象集合并通知每个对象进行交换。