Double Buffer-Game Programming Patterns双缓冲-游戏编程模式(上)

来源:互联网 发布:魔兽争霸3 原生 mac 编辑:程序博客网 时间:2024/06/05 20:35

Double Buffer-Game Programming Patterns(上)

双缓冲——游戏编程模式(上)


目的

通过一系列顺序性的操作,使效果能够瞬时和同时的呈现出来。


动机

计算机本身是一种连续性的野兽。他们的能力自来自于能够将大的任务分解成微小的步骤,这些微小的步骤可以被一个一个的执行。通常,考虑到我们的用户需要看到事物的出现在一个单一的瞬时步骤中或者看到多个任务被同时执行。

一个典型的例子是每个游戏引擎都必须到达的地方——渲染。当游戏绘制出世界给用户看时,它一次处理一块——远处的山,起伏的丘陵,树林,每个都会按照它们的顺序。如果用户看到的物体增加了,这种连续的视觉效果就会被打破。场景必须快速而且平滑的更新显示在完整的帧中,每一次都是瞬时的。

双缓冲解决了这个问题,但是为了理解其中的原因,我们首先需要重新审视计算机是如何显示图像的。

计算机的图形绘制是如何工作的(简要的介绍)

一个视频的显示需要电脑显示器每次绘制一个像素。它从左上角开始,从左向右扫描过每一行,然后移动到下一行。当它到达底部的最右端时,它又从左上角重新开始扫描。它做的是非常的快——大约整个过程能够到达每秒60次,以至于我们的眼睛看不到扫描的过程。对我们而言,这只是一个被上色的静态区域—— 一个图片。

你可以想象这个过程就像一个微小的软管,管道中的像素用来显示。单个的颜色从软管的后面喷洒到显示器上,按照一个颜色对应一个像素的顺序来。那么,软管需要的这些颜色在哪儿呢?

在大多数的计算机中,这个回答是颜色来自于帧缓冲区。一个帧缓冲区是内存中的一个像素数组,一块RAM中每两个字节代表一个像素的颜色。当软管喷洒到显示器上时,它会从数组中读取颜色的值,每次读取一个字节。(备注:读取操作是由显示驱动器执行的——它也是一段程序)

从根本上来讲,为了得到游戏所显示的界面,所有我们要做的就是填充这个数组。所有疯狂的关于图形的高级算法我们已经表现了出来,就是:设定字节值在帧缓存中。但是这里会有一个小问题。

让我们在屏幕上显示一个笑脸。我们的程序使用循环的操作将像素信息写入这个帧缓存。我们没有意识到,显示驱动器从帧缓存中取出了数据,但这时候我们正在将数据写到帧缓存中。当显示驱动它扫描每一个写过的像素,我们的脸开始呈现了。但是它超过了我们写的速度,也就是移动的像素我们还没来得急写上去。那么结果是可怕的,一个视觉bug会出现在这种情况下——你会看到屏幕上一些东西只绘制了一半,画面裂开了。



这就是我们需要这个模式的原因。我们的程序每次只渲染一个像素。但是我们的显示驱动器需要立刻看到全部的像素——我们来看一个特殊的帧,这个帧上没有人脸,紧接的下一帧,这张脸就出现了( in one frame the face isn’t there, and in the next one it is.)。双缓冲解决了这个问题。我会通过类比进行解释。


一个行为,一个场景

想象我们的用户正在观看我们制作的剧本。当场景1结束并且场景2开始时,我们需要改变这个舞台的设置。如果我们已经有一个舞台管理员运行在场景的后面并开始拖动道具,连贯的显示就会被打破。当我们做这件事的时候应该调暗灯光(这也是我们真正的威胁),但是观众知道一些东西在继续着。我们希望这里没有间隙的切换这两个场景。

根据这些真实的情况,我们提出一个聪明的解决方案:我们建了两个舞台,观众可以看两个。每一个都设定了他们自己的灯光。我们称他们为舞台A和舞台B。场景1在舞台A上显示。与此同时,场景B是在暗地里布置场景2。只要场景1一结束,我们就关掉舞台A的灯光,打开舞台B的灯光。观众就会看向新的舞台,场景2就立刻开始了。

在此期间(场景2播放的期间),我们的舞台管理人员管理调暗灯光的舞台A,收拾了场景1并搭建场景3。当场景2一结束我们又一次切换灯光到舞台A。.我们持续的进行着,在整个剧本中,使用暗的舞台作为工作区域,这里我们可以为下一个场景做准备。每一次场景的转换,我们仅仅在这两个舞台之间切换灯光。我们的观众得到一个持续的表演,这个表演在场景的切换中是没有延迟的。观众也没看到任何一个舞台管理员。


回到图形绘制中

上面是一个精确的双缓冲机制的工作方式,这种处理作为渲染系统的基础,在你所有见到过的所有游戏中出现。不同于单个的帧缓冲区域,我们有两个缓冲区。他们中的一个用来显示当前的帧,类比场景A。这个帧是显示硬件的读取的地方。GPU可以尽可能的通过它扫描想要的东西。

与此同时,我们的渲染代码(写操作)写在另外一个帧缓冲区中。这个是我们暗了的舞台B。当我们的渲染代码要绘制这个场景的时候,它交换了缓冲区就像切换灯光一样。这里告诉视频硬件区开始从第二个缓冲区读取数据而不是第一个。只要在切换缓冲区之前,我们不会遇到任何画面裂开的问题,而且整个场景(在切换时)会立刻出现。

与此同时,老的帧缓冲区现在对我们可用了,我们开始用它渲染下一帧。


双缓冲模式

一个缓冲类封装一个缓冲区:每个缓冲区被认为是一个区块,区块是可修改的。这个缓冲区被逐渐的修改,但是我们想在外部代码中看到这个修改操作作为一个单一的原子形式。为了做到这种效果,这个类保持了两个缓冲区的实例:一个是<下一个缓冲区>,一个是<当前的缓冲区>。

信息总是从<当前缓冲区>中读取信息,向<下一个缓冲区>中写入信息。当变化完成之后,一个交换操作直接的交换了<当前缓冲区>和<下一个缓冲区>,新的缓冲区立刻就可以被使用。而老的<当前缓冲区>现在可以被重用,作为新的<下一个缓冲区>.


什么时候使用它

有些模式当你需要用到的时候,你自然会想起。它就是其中之一。如果你有一个系统缺少双缓冲,它可能看起来视觉上是很糟糕的(撕裂等)。或者出现错误的行为,但是说“你会知道什么时候需要他”,这其实没有传达什么信息。更确切的说,当下面这些条件满足的时候,这个模式是合适的:

1.我们有一些区块可以被逐渐的修改。

2.有相同区块可以在修改过程中访问。

3.我们想要阻止代码在区块被修改时访问它。

4.我们想要读取区块而且不想等待写操作。


记在心里

不同于大的高层次的模式,双缓冲存在一个低层次水平的实现。因为这个原因,剩下很少的基本代码——大多的游戏甚至不会知道到这个有双缓冲在使用。这里仍有一些警告:

1.交换需要的时间

双缓冲需要一个交换的步骤每一个区块被修改完毕时。这个操作必须是一个原子性的——没有代码可以介入区块的交换过程。通常,这个过程和分配一个指针一样快,但是如果交换所话费的时间与它修改区块最开始的位置的时间相比更长,那么我们不能得到任何的帮助。

2.我们不得不拥有两个缓冲区

这个模式的另外一个结果就是我们增加了缓存的使用。就像它名字说的,这个模式需要你保持两个内存区块的复制,在所有的时间上。在内存显示的设备上,这是一个较大的代价。如果你不能支付两个缓存区,为了确保你的区块在修改的时候没有被访问,你可能不得不寻找其他的道路。


样例代码

现在我们已经理解了这个理论,让我们看看它是如何在实际中工作的。我们能够写一个非常简单的图像系统,能让我们在帧缓冲区中绘制像素。在多数的控制台和PC机上,显示驱动器提供了它的低层次的渲染系统,但是自己实现它将会让我们看到渲染系统是如何变化的。第一步是缓冲区自身:

<span style="font-size:14px;">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];};</span>
他有基本的操作,对于清除全部的缓存用一个默认的颜色来设置自身的像素颜色。它也有另外一个功能,getPixels(),为了给出原本的数组内存中包含的数据。我们不能看到实际的例子,但是视频驱动器会频繁的调用这个功能把屏幕缓冲区数据到搬运到流内存中。

我们在场景类中包装了这个基本的缓存类。它通过一些draw()方法的调用,渲染一些东西到包装的缓冲区中。

<span style="font-size:14px;">class Scene{public:  void draw()  {    buffer_.clear();    buffer_.draw(1, 1);    buffer_.draw(4, 1);    buffer_.draw(1, 3);    buffer_.draw(2, 4);    buffer_.draw(3, 4);    buffer_.draw(4, 3);  }  Framebuffer& getBuffer() { return buffer_; }private:  Framebuffer buffer_;};</span>

每一个帧,游戏会告诉场景区绘制什么。这个场景清除了缓冲区,而且在这一时刻绘制了一批像素。它也提供了访问这些内部缓冲数据的方式通过getBuffer(),所以显示驱动器可以得到它。

这看起来很直接,但是如果只做到这种程度的话,我们将会遇到一个问题。这个问题是视频驱动器调用getPixels()在这个缓冲区中在任意一个时间,甚至在这儿:

<span style="font-size:14px;">buffer_.draw(1, 1);buffer_.draw(4, 1);// <- Video driver reads pixels here!buffer_.draw(1, 3);buffer_.draw(2, 4);buffer_.draw(3, 4);buffer_.draw(4, 3);</span>
当这个发生时,用户将会看到脸上的眼睛,但是嘴会消失在这个帧中。在下一帧中,它会被打断在一些其他的点。最终的结果是一个可怕闪烁图形。我们用双缓冲来修正:

<span style="font-size:14px;">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()  {    // Just switch the pointers.    Framebuffer* temp = current_;    current_ = next_;    next_ = temp;  }  Framebuffer  buffers_[2];  Framebuffer* current_;  Framebuffer* next_;};</span>
现在场景中含有两个缓冲区,存储在buffers_数组中。我们没有从这个数组中直接的引用他们。相反,这里有两个成员,next_和curent_,他们指向数组。当我们绘制时,我们绘制到next_指针指向的缓冲区中。当我们的视频驱动需要得到像素时,它总是通过current_指针访问另一个缓冲区。
这种方式,显示驱动器再也不用看这个缓冲区是不是正在执行写操作。剩下的令人疑惑的一块是swap()方法的调用,当场景做完了帧的绘制操作之后。简单的交换next_和current_两个指针的引用即可。下一次视频驱动器调用getBuffer()的时候,它会获得我们绘制过后的新的缓冲区,并将最近一次被绘制的缓冲区显示到屏幕上。不再有裂开和难堪的小故障。


双缓冲 翻译 (下):http://blog.csdn.net/u012289636/article/details/46864143

0 0