《游戏程序设计模式》 2.1 - 双缓冲

intent

    使一系列顺序操作瞬时或同步出现。

motivation

    在他们心中,计算机是顺序野兽。它的力量来源于把大的工作分成很多个小步骤一个接一个的执行。尽管一般是,我们的用户看到的是一个单一瞬时的任务或多任务同时执行。

    一个经典的例子,而且是每个游戏引擎都会涉及的是渲染。当游戏绘制世界时,它一次要做这么一件事-远处的的山,起伏的坡,树木,这些个轮流。如果玩家看到以增量方式绘制画面,那么连贯世界的景象会破裂掉。场景必须平滑而快速的更新,展示一连串完整的帧,每一帧都要立刻出现。

    双缓冲可以解决这个问题,但是要理解如何解决,我们要先回顾计算机如何显示图形。

how computer graphics work(briefly)

    像计算机显示器显示图像是一次只能绘制一个像素。它从左到右横扫每一行然后移动到下一行。当它到达右下角之后,它又回到左上角重复之前的动作。它做得很快-大约一秒60次-以至于我们的眼睛看不到这个扫描过程。对我们来说,它就成了一个彩色像素的静态区域-一张图片。

    你可以想象成用一个水管把像素射到屏幕。不同的像素从水管后部进入,然后水管把它们喷射到屏幕。那么,水管如何知道一个像素应该被射到哪里?

    在多数计算机中,答案是水管从一个帧缓冲中取得像素。一个帧缓冲是内存中的一个数组用来存储像素的,一块RAM,每几个字节代表一个像素。当水管要喷射像素时,它从这个数组里一个字节一个字节地读取颜色值。

    最终,为了使游戏画面显示,我们要做的就是把数据写到这个数组中。所有疯狂先进的图形算法浓缩一下就是:设置帧缓冲的字节值。但是有一个小问题。

    上面,我说过计算机是顺序的。如果机器正在执行渲染代码,我们不要期望它同时做别的事。大多数是对的,但是还是会出现一些问题在程序运行中。其中之一就是,当游戏运行显示视频时会持续从帧缓冲读取数据。这会导致一个问题。

    我们假设我们要在屏幕上显示一个笑脸。游戏开始循环向帧缓冲中填充颜色。我们没意识到的是当我们向帧缓冲写数据时,视频驱动正从中读取数据。随着它扫描我们已经写入的数据,笑脸开始出现,但是它超过了我们,进入我们还没写入的内存。结果就是“撕裂”,一个丑陋的可见的bug出现在屏幕上。

    《游戏程序设计模式》 2.1 - 双缓冲_第1张图片

    这就是为什么我们需要这个模式。我们的程序一次只能绘制一个像素,但是视频驱动却要立即看到全部的像素-在一帧没有笑脸,下一帧出现一个笑脸。双缓冲可以解决这个。我会通过分析进行解释

act 1, scene 1

    假设用户正在观看我们做的戏剧。场景1结束,场景2开始,我们需要改变舞台布置。如果等场景结束才开始拖动道具,就会破坏连贯性。我们可以灭灯当这么做时(当然,真正地剧院就是这么做的),但是观众仍然知道发生了什么事。我们想使场景之间没有间隔。

    我们提出一种方法:我们建造两个观众都能看到的舞台。每一个有自己的灯光。我们称为舞台a,舞台b。场景1在舞台a上,同时舞台b灭灯,由工作人员布置场景2。场景1一结束,舞台a灭灯,舞台b亮灯。观众看向舞台b,场景2立即开始。

    同时,工作人员又跑到舞台a,开始布置场景3。场景2一结束,舞台a又重新亮灯。我们在整个戏剧中进行这种处理,总是在灭灯的舞台布置下一个场景。每一个场景切换,我们只需切换两个舞台的灯光。观众看到一个没有间隔的连续的表演。他们从未看到一个工作人员。

back to graphics

    这就是双缓冲工作原理,这个过程成为你见到的所有游戏的渲染系统的基础。我们使用两个帧缓冲,而不是一个。一个表示当前帧。它是视频卡要读取的那个。无论什么时刻,只要GPU想扫描它就可以扫描。

    同时,程序向另一个帧缓冲写数据。当渲染代码绘制完毕,通过交换(swap)帧缓冲实现灯光切换。这个告诉显卡从第二个帧缓冲读取数据而不是第一个。只要在刷新时交换,画面就不会撕裂,整个场景立刻会出现。

    同时,前一个帧缓冲就可以使用了,我们开始向其中渲染下一个场景。欧耶。

The pattern

    用一个类封装缓冲区:一块可以修改的状态。这个缓冲区是以增量的方式编辑的,但是我们希望外部代码,以原子方式读取整个缓冲区。为此,类保留两个缓冲区的实例:一个当前(current)缓冲,一个下一个(next)缓冲。

    当从缓冲区读取信息时,总是从current缓冲读。当向缓冲区写数据时,总是向next缓冲区写。当写入完毕,一个swap操作立即交换两个缓冲,这样新缓冲就对外可见了。原先的current就变成了next了。

when to use it

    这个模式是你知道应该何时使用它的模式之一。如果你有个系统,不使用双缓冲就会明显出错(画面撕裂)或者行为出错。但是,“你知道何时使用它”并没有告诉你太多。更明确得,当下列情况为真时,这个模式是合适的:

  •     有一些状态,增量地修改。

  •     这些状态可能会在修改中被访问。

  •     我们想要阻止代码看到修改的过程。

  •     我们想要读取状态但不想被状态的改写所阻塞。

Keep in mind

    不像大型的架构模式,双缓冲是一个更底层的实现。因此,它对其他代码有很小的联系-大多数代码甚至不知道它的存在。然而,还是有几条警告。

the swap itself takes time

    双缓冲需要一个交换操作一旦状态发生了改变。这个操作必须是原子的-当交换时其他代码不能访问任何一个状态。一般,这与为指针赋值一样快,但是如果它花的时间比修改状态的时间长的话,那就没有意义。

we have to have two buffers

    另一个影响是增加内存使用。顾名思义,这个模式需要两份状态的副本。对有内存限制的设备,这可能是个很大的代价。如果你无法承担两个buffer,那么你就要用其他的方法来保证,当修改状态时不让其它代码访问状态。

sample code

    现在,我们了解了理论,让我们看看实际中如何工作。我们将写一个有基本要素的图形系统,可以让我们在帧缓冲中绘制像素。在大多数主机和pc中,显卡提供了图形系统底层的部分,但是手动实现它会让我们明白到底怎么回事。首先是缓冲:

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];
};

    它有一些基本的操作,用白色清空缓冲,单独设置一个像素的值。还有一个函数getPixels()获取缓冲。我们不会在例子中看到它,但是显卡会经常调用它把像素喷射到屏幕上。我们把这个缓冲封装进Scene类中。它的工作是调用许多draw()来绘制东西:

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_;
};

    每一帧,游戏都会通知Scene绘制。Scene每次会clear,然后调用许多draw。它也提供访问内部buffer的接口getBuffer,所以显卡可以访问它。

    这个好像相当直白,但是如果我们这样看,它将出现问题。问题是显卡可以在任意时间调用getBuffer,甚至像这样:

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);

    当发生时,这帧只看到眼睛,而嘴巴消失了。下一帧,不知道又在什么地方打断绘制。最终结果就是可怕的忽隐忽现的图像。我们将通过双缓冲解决问题:

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_;
};

    现在scene有两个buffer了,存在buffer_数组中。我们不直接引用数组,而是用两个指针next_和current_指向数组。当我们绘制时,我们向next_中绘制。当显卡需要读取像素时,总是从current_中读取。

    这样,显卡从不会访问正在写入的buffer。唯一的谜团是当我们绘制完swap()。交换操作就是简单地交换next_和current_。下一次显卡调用getBuffer(),就是取得的刚绘制完的buffer,并把它喷射到屏幕上。不会再有撕裂和难堪的小问题。


你可能感兴趣的:(《游戏程序设计模式》 2.1 - 双缓冲)