双缓冲技术讲解

旧街凉风 提交于 2019-11-29 13:21:22

笔者介绍:姜雪伟,IT公司技术合伙人,IT高级讲师,CSDN社区专家,特邀编辑,畅销书作者,国家专利发明人;已出版书籍:《手把手教你架构3D游戏引擎》电子工业出版社和《Unity3D实战核心技术详解》电子工业出版社等。

CSDN视频网址:http://edu.csdn.net/lecturer/144

首先要搞清楚计算机运行原理,计算机载运行时是将将最大的任务分解成多个任务,然后一个接一个地执行。 一个典型的例子,每个游戏引擎必须解决的问题是渲染。 当游戏画出用户看到的世界时,比如远处的山脉,连绵起伏的山丘,树木逐步渲染出来。 如果用户以这种方式逐步观看视图,那么一个连贯世界的错觉将会被打破。 场景必须快速地更新,显示一系列完整的场景,场景中每个对象都是立即出现。

而双缓冲技术就是解决这个问题,但要了解如何,我们首先需要检查计算机如何显示图形。像计算机显示器一样的视频显示器一次绘制一个像素。 它从左到右扫过每行像素,然后向下移动到下一行。 当它到达右下角时,它会扫描回到左上角,并重新开始。 它每秒大约六十次 也就是我们通常说的帧率- 我们的眼睛看不到扫描。 对我们来说,它是彩色像素的单个静态场景 - 一个图像。

你可以想象这个过程,就像一个将像素管理到显示器的小软管。 单独的颜色进入软管的背面,并将它们喷射到显示屏上,并向其中的每一个像素提供一点颜色。 那么软管怎么知道什么颜色去哪里?

在大多数计算机中,答案是它从一个帧缓冲区中提取出来, 帧缓冲器是存储器中的像素阵列,RAM的一大块,其中每对字节表示单个像素的颜色。 当软管喷射在显示器上时,它会读取该阵列的颜色值,一次一个字节。

最终,为了让我们的游戏出现在屏幕上,我们所做的就是写入阵列。 所有这些疯狂的高级图形算法,我们归结为:在framebuffer中设置字节值。 但有一点问题。

早些时候 如果计算机正在执行我们的渲染代码的一大块,我们不期望它在同一时间做任何其他事情。但是在我们的程序运行过程中,有几件事情会发生。 其中之一是,我们的游戏运行时,视频显示将不断从framebuffer读取。 这可能会给我们带来问题。

假设我们想要一张幸福的脸孔出现在屏幕上 我们的程序开始循环帧缓冲区,着色像素 我们没有意识到的是,视频驱动程序正在写入帧缓冲区。 当它扫描我们写的像素时,我们的脸开始出现, 结果是像素撕裂,一个可怕的视觉错误,你看到屏幕上画的一半东西。

这就是为什么我们需要双缓存模式 我们的程序一次渲染像素,但是我们需要显示驱动程序来一次看到它们 - 在一个画面中,面部不在那里,而在下一个缓存 双缓冲解决了这一点。 我会通过类比来解释一下。

想象一下,我们的用户正在观看自己制作的游戏随着场景一结束,场景二开始,我们需要改变舞台设置。 如果我们在舞台上跑步,开始拖动道具,一个连贯的地方的错觉就会被打破。 当我们这样做时,我们可以使灯光昏暗(这当然是真正的剧院所做的),但观众仍然知道事情正在发生。 我们希望在场景之间没有时间差距。

我们想出了这个聪明的解决方案:我们建立两个阶段,观众可以看到两个阶段, 每个都有自己的一套灯光, 我们将它们称为舞台A和舞台B。场景一显示在舞台A上。同时,舞台B是黑暗的,舞台正在设置场景二。 一旦场景结束,我们将舞台A上的灯光切开并将其放在舞台B上。观众看到新的舞台,场景二开始立即开始。

同时,我们的舞台已经在现在黑暗的A舞台上结束,引人注目的场面,并设置了三场。 一旦场景二结束,我们再次将灯光切换回到舞台A。 我们继续这个整个游戏的过程,使用黑暗的舞台作为我们可以设置下一个场景的工作区域。 每个场景过渡,我们只是在两个阶段之间切换灯光。 我们的观众在场景之间不间断地表现出来。 他们从来没有看到舞台。

这正是双缓冲的工作原理,这个过程是您所见过的每一个游戏的渲染系统的基础。 而不是单个帧缓冲区,我们有两个。 其中一个代表当前的框架, GPU可以随时扫描它,只要它想要。

同时,我们的渲染代码正在写入另一个帧缓冲区。 这是我们黑暗的舞台B.当我们的渲染代码完成绘制场景时,它通过交换缓冲区来切换灯光。 这告诉视频硬件现在开始从第二个缓冲区读取,而不是第一个缓冲区。 只要在刷新结束时进行切换,我们就不会有任何撕裂,整个场景将立即出现。

同时,旧的framebuffer现在可以使用。 我们开始渲染下一帧。哈哈哈!!!

缓冲类封装了一个缓冲区:一段可以被修改的状态。 这个缓冲区是逐渐编辑的,但我们希望所有的外部代码可以将编辑看作一个单一的原子变化。 为此,该类保留缓冲区的两个实例:下一个缓冲区和当前缓冲区。

当从缓冲区读取信息时,它始终来自当前的缓冲区 当信息写入缓冲区时,会发生在下一个缓冲区中。 当更改完成时,交换操作会立即交换下一个缓冲区和当前缓冲区,以便新的缓冲区现在公开显示。 旧的当前缓冲区现在可以重新用作新的下一个缓冲区。

与较大的架构模式不同,双缓冲存在于较低的实施级别。 因此,对代码库的其余部分的影响较小 - 大多数游戏甚至不会意识到差异。

这种模式的另一个后果是增加内存使用 顾名思义,该模式要求您始终在内存中保留两个副本。 在内存受限的设备上,这可能是一个沉重的代价。 如果您不能负担两个缓冲区,您可能需要考虑其他方式来确保在修改期间不会访问您的状态。

现在我们已经有了理论,让我们来看看它在实践中如何运作。 我们将编写一个非常简单的图形系统,让我们在帧缓冲区上绘制像素。 在大多数控制台和个人电脑中,视频驱动程序提供了图形系统的这个低级部分,但是手动实现它将让我们看看发生了什么。 首先是缓冲区本身:

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_;
};
每一帧,游戏告诉现场画画。 场景清除缓冲区,然后绘制一个像素,一次一个。 它还通过getBuffer()提供对内部缓冲区的访问,以便视频驱动程序可以访问它。

这似乎很简单,但如果我们这样离开,我们将遇到问题。 麻烦的是,视频驱动程序可以在任何时候调用缓冲区中的getPixels(),甚至在这里:

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_;
};
现在场景有两个缓冲区,存储在buffers_数组中。 我们不直接从数组中引用它们。 相反,有两个成员next_和current_指向数组。 当我们绘制时,我们绘制到next_引用的下一个缓冲区。 当视频驱动程序需要获取像素时,它总是通过current_访问另一个缓冲区。

这样,视频驱动程序就不会看到我们正在处理的缓冲区。 唯一剩下的一个难题就是当场景完成画面时调用swap()。 通过简单地切换next_和current_引用来交换两个缓冲区。 下一次视频驱动程序调用getBuffer()时,它将获取刚完成绘制的新缓冲区,并将最近绘制的缓冲区放在屏幕上。 没有更多的撕裂或难看的画面

双缓冲解决的核心问题是在修改状态时被访问 这有两个常见的原因。 我们已经用我们的图形示例覆盖了第一个,状态直接从另一个线程或中断的代码访问。

还有另一个同样常见的原因,当修改的代码访问正在修改的相同状态时 这可以在各种各样的地方,特别是物理学和人工智能,其中实体互相交互。 双缓冲通常也是有用的。

假设我们正在建立一个基于闹剧喜剧的游戏的行为体系 这个游戏有一个舞台,里面包含一大堆演员, 这是我们的基地演员:

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_;
};
每一帧,游戏负责调用actor上的update(),以便它有机会进行一些处理。 从关键的角度来说,从用户的角度看,所有的演员都应该同时进行更新。

演员也可以相互交流,如果通过“互动”,我们的意思是“他们可以相互拍打”。 当更新时,actor可以在另一个actor上调用slap()来敲击它并调用wasSlapped()来确定是否已经被打了。

演员需要一个可以互动的舞台,所以让我们来构建一下:

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];
};
舞台让我们添加演员,并提供更新每个演员的一个update()调用。 对于用户,演员似乎同时移动,但在内部,它们一次更新一个。
唯一要注意的一点是,每个演员的“拍打”状态在更新后立即被清除, 这样一来,一个演员只能回应一下给定的一声。
为了让事情顺利进行,我们来定义一个具体的actor子类
我们这个喜剧演员很简单。 他面对一个演员。 每当他被任何人击毙时,他都会通过拍打他所面对的演员来回应。

class Comedian : public Actor
{
public:
  void face(Actor* actor) { facing_ = actor; }

  virtual void update()
  {
    if (wasSlapped()) facing_->slap();
  }

private:
  Actor* facing_;
};
现在让我们把一些喜剧演员放在一个舞台上,看看会发生什么。 我们将设置三位喜剧演员,每人都面向下一个。 最后一个将面对第一个,在一个大圆圈:

Stage stage;

Comedian* harry = new Comedian();
Comedian* baldy = new Comedian();
Comedian* chump = new Comedian();

harry->face(baldy);
baldy->face(chump);
chump->face(harry);

stage.add(harry, 0);
stage.add(baldy, 1);
stage.add(chump, 2);
所得阶段的设置如下图所示。 箭头显示演员所面对的人物,数字显示舞台阵列中的索引。

我们将离开舞台设置的其余部分,但是我们将替换代码块,我们将演员添加到舞台中:

stage.add(harry, 2);
stage.add(baldy, 1);
stage.add(chump, 0);
让我们看看当我们再次运行实验时会发生什么:

Stage updates actor 0 (Chump)
  Chump was not slapped, so he does nothing
Stage updates actor 1 (Baldy)
  Baldy was not slapped, so he does nothing
Stage updates actor 2 (Harry)
  Harry was slapped, so he slaps Baldy
Stage update ends
最终的结果是,一个演员可能会完全依赖于两位演员如何在舞台上被命令而作出的响应。 这违反了我们的要求,即演员需要并行运行 - 他们在单个框架内更新的顺序不重要。

幸运的是,我们的双缓冲模式可以提供帮助 这一次,我们将以更精细的缓冲,而不是一个单一的“缓冲区”对象的两个副本:每个actor的“slapped”状态:

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_;
};
现在每个演员都有两个,而不是一个slapped_状态。 就像之前的图形例子一样,当前状态用于读取,下一个状态用于写入。
reset()函数已被swap()替换。
现在,在清除交换状态之前,它将下一个状态复制到当前状态,使其成为新的当前状态。 这也需要在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();
  }
}
双缓存的工作原理是:交换操作是进程最关键的一步,因为我们必须在发生这两个缓冲区时锁定所有读取和修改。 为了获得最佳性能,我们希望尽快发生这种情况。

速度很快 不管缓冲区有多大,交换只是一些指针分配。 这是很难打败的速度和简单。
外部代码不能存储持久指针到缓冲区。 这是主要的限制。 由于我们实际上没有移动数据,所以我们本来在做的是定期告诉其余的代码库,看看其他地方的缓冲区, 这意味着代码库的其余部分不能直接存储指向缓冲区内的数据的指针 。
缓冲区上的现有数据将来自两帧前,而不是最后一帧。 连续的帧是在交替的缓冲区上绘制的,没有在它们之间复制数据,像这样:

Frame 1 drawn on buffer A
Frame 2 drawn on buffer B
Frame 3 drawn on buffer A
...
您将注意到,当我们去绘制第三帧时,已经在缓冲区上的数据来自第一帧,而不是最近的第二帧。 在大多数情况下,这不是一个问题 - 我们通常在绘制之前清除整个缓冲区。 但是,如果我们打算在缓冲区中重用一些现有的数据,那么考虑到这些数据将比我们预期的更早一些。

如果我们不能将用户重新连接到其他缓冲区,唯一的其他选项是将下一帧的数据实际复制到当前帧。 在这种情况下,我们选择了这种方法,因为状态 - 一个单一的布尔标志 - 不再需要复制,而不是指向缓冲区的指针。

下一个缓冲区上的数据只有一个旧帧。 这是复制数据而不是在两个缓冲区之间来回ping通的好东西。 如果我们需要访问先前的缓冲区数据,这将为我们提供更多最新的数据。

交换可以花更多的时间 这当然是大负面的一点。 我们的交换操作现在意味着将整个缓冲区复制到内存中。 如果缓冲区很大,就像整个帧缓冲区一样,这样做可能需要很的时间。 由于在发生这种情况时,没有任何内容可以读取或写入缓冲区,这是一个很大的限制。

另一个问题是缓冲区本身是如何组织的 - 它是一个单一的整体数据块还是分布在对象集合之间? 我们的图形示例使用前者,演员使用后者。
大多数时候,你正在缓冲的本质会导致答案,但有一些灵活性。 例如,我们的演员都可以将他们的消息存储在一个消息块中,它们都由它们的索引引用。

交换更简单 由于只有一对缓冲区,所以只能进行一次交换。 如果您可以通过更改指针进行交换,那么您可以使用几个任务来交换整个缓冲区,而不考虑大小。

交换速度较慢 为了交换,我们需要遍历整个对象集合,并告诉每个对象进行交换。
在我们喜剧演员的例子中,这样做是可以肯定的,因为我们需要清除下一个拍子的状态 - 每个缓冲状态都需要被触摸每一帧。 如果我们不需要另外触摸旧的缓冲区,我们可以做一个简单的优化,以便在跨多个对象分发缓冲区时获得同样的性能的单片缓冲区。
这个想法是获得“当前”和“下一个”指针概念,并将其转换为对象相对偏移量,将其应用于每个对象。 像这样:

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];
};
演员通过使用current_来索引到状态数组来访问其当前拍照状态。 下一个状态总是数组中的另一个索引,所以我们可以用next()来计算。 交换状态只是替换current_ index。 聪明的一点是,swap()现在是一个静态函数 - 它只需要调用一次,并且每个actor的状态都将被交换。

总结:

其实我们现在使用的DirectX或者OpenGL都使用了双缓存技术,在这里给读者只是揭示一下其实现原理,加深读者对于双缓存的认识和理解。

易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!