游戏编程设计模式——Double Buffer
发表于2016-03-18
1、意图
使一系列操作看起来像瞬间或者同时发生的。
2、动机
计算机的心脏在不停地跳动着。他们的力量体现在,能够把一个很大的任务拆分成能够一个个完成的小步骤。通常我们的用户希望看到一个任务能够瞬间完成,或者很多任务能够同时进行。
一个典型的例子,渲染是每个游戏引擎都应该具备的功能。当游戏绘制玩家所见的世界时,它在一小片时间内画了——远处的大山,起伏的山丘,树木,一个接一个的画。如果用户能够看到这个渐进的绘制过程,会产生山河破碎的感觉。所以,场景必须能够平滑快速的更新,迅速显示一连串渲染完成的帧。
双缓冲就是为了解决这个问题,但是为了能够让大家明白如何实现的,我们先要回顾一下计算机是如何显示图形的。
3、计算机图形如何工作(简短)
计算机显示器同时只能绘制一个像素。它从左到右扫描每一行的像素,然后向下扫描下一行。当扫描到右下角时,在返回到左上角重新来过。它非常快——约每秒进行60次——所以我们的眼睛看不到扫描过程。对我们来说,它生成的就是一个充满了色彩像素的区域——即图像。
你可以把这个过程想象成用一根细管向显示器上喷颜色。不同的颜色进入到细管的另一头,然后把喷撒到显示屏上,每个像素轮流得到一些颜色。但是细管是怎么知道把什么颜色放到什么地方呢?
多数计算机中,答案是放在一个叫帧缓冲区的地方。一个帧缓冲区就是在内存中的一个像素数组,在一块RAM中,几个字节存储了一个像素的颜色。当细管要在显示屏上喷洒时,它从这个数组中读取颜色,每次一个字节。
最终,为了在屏幕上显示我们的游戏画面,我们要做的就是往这个数组中写入数据。所有那些疯狂的高级图形算法归根结底就是去设置这个帧缓冲区每一个字节的值。但是这里有一个问题。
前面我说过,计算机是顺序执行的。如果机器正在执行我们的渲染代码,我们并不希望它做别的事情。那只是理想情况,在我们的代码执行过程中,的确发生了很多事情。其中一个便是图像显示部分会在我们的游戏运行期间从帧缓冲区中读取数据。这会引起一些问题。
例如我们想在屏幕上显示一张笑脸。我们的程序开始循环写入帧缓冲区,为像素着色。但是,我们没有意识到,显卡驱动程序正在从帧缓冲区中读取数据,正如我们写入一样。随着它不断扫描我们写入的数据,我们的笑脸开始出现。然后它超越了我们,进入了我们还没有来得及写入的内存区。结果很蛋疼,一个显示bug就是你在屏幕上只能看到半张笑脸。
这就是为什么我需要这个模式。我们的渲染程序每次只能写入一个像素,但是我们需要显示驱动一次显示整个帧——上一帧还没有笑脸,下一帧就全部显示出来。双缓冲就解决了这个问题。我接下来会讲一下如何实现的。
4、演员1,场景1
想象一下,我们的观众正在欣赏我们制作的一场戏剧。当第一个场景结束第二个场景开始的时候,我们需要改变场景布局。如果我们的舞台工作人员在结束后跑上舞台,开始拉拽道具,感观的一致性就会被破坏。我们可以调暗灯光(实际上也是这么做的),但是观众仍然知道这些事情正在进行。我们希望在场景之间没有停顿。
在实际工作中,我们找到了一个聪明的解决方案:我们建造两个观众可以同时看到的舞台。每一个都有自己的一套灯光。暂且把他们叫做舞台A和舞台B。第一个场景在舞台A上表演。与此同时,舞台B是暗的,这是工作人员可以在上面布置第二个场景的道具。一旦第一个场景表演结束,我们关掉舞台A的灯光,开启舞台B的。观众们马上就可以看到一个新的舞台和第二个场景。
这个时候,我们的工作人员可以到暗下去的舞台A,撤下第一个场景并布置第三个场景。当第二个场景结束时,我们在把灯光切换到舞台A。整个演出我们就可以这样进行下去,把那个暗下去的场景作为布置下一个场景的工作区。每一个场景切换,我们只需要在舞台见切换灯光。我们的观众可以无延迟得连续观看,而看不到一个工作人员。
5、回到图形
这正式双缓冲的工作方式,你见过的几乎所有的游戏,它的渲染系统都使用了这个过程。不再使用一个帧缓冲区,我们用两个。一个提供当前帧的数据,也就是我们前面那个比喻中的舞台A。GPU可以随时访问到它。
同时,我们的渲染代码在写入另外一个帧缓冲区。这就是我们暗下去的舞台B。当渲染代码完成了场景的绘制,就翻转缓冲区就像切换灯光一样。这就告诉图形硬件去读取第二个缓冲区,而不要读第一个了。只要刷新后切换缓冲区,我们就不会感觉到延迟,整个场景会在瞬间显示。
然后,那个老帧缓冲区就可以用了。我们开始在上面渲染下一帧。多好!
6、模式
一个被缓冲的类封装了一个缓冲区:一些可修改的状态。这个缓冲区的修改是逐渐进行的,但是我们希望让外部的代码认为这个修改是一个原子操作。为了做到这一点,这个类维护了两个缓冲区的实例:下一个缓冲区和当前的缓冲区。
信息总是从当前缓冲区中读取。当然,往下一个缓冲区中写入。当写入完成时,瞬间交换两个缓冲区,这样新缓冲区就可以公开了。当然,老缓冲区就充当了下一个缓冲区的角色。
7、什么时候用
这个模式,就是那些当你用到的时候就想起的东西。如果你有一个没用双缓冲的系统,它很可能看起来是错误的(比如开裂)或者行为怪异。但是“当你用到的时候就想起”并不代表什么。这里有更具体的条件,如果都满足,那这个模式就适合:
(1)、有一些需要逐渐修改的状态。
(2)、同一个状态在修改的过程中,可能被访问。
(3)、我们要防止在修改过程中访问这些状态。
(4)、我们想读取这些状态,但是不想等到写完后再访问。
8、牢记
不像那些大的结构化模式,双缓冲的实现在很底层。因此它没有太大的影响力——很多游戏甚至并没有意识到有什么可注意的。但是仍然有几个坑。
9、交换过程仍然占用时间
双缓冲区在被更新之后,需要一个交换的步骤。这个操作必须是原子操作——在交换的时候不能有代码访问到任意一个缓冲区。通常,这只是一个修改指针的操作,如果这个过程比修改过程长,那就完蛋了。
10、我们必须有两个缓冲区
这个模式带来的另一个结果就是增加内存占用。就像模式名字描述的那样,它要求我们在内存中始终保留两份缓冲区。在内存受限的设备中,这个代价显得比较大。如果你不能提供两个缓冲区,那就必须找另外的方法保证这些状态在修改的时候不会被访问。
11、简略代码
现在我们已经明白了原理,接下来让我们看一下在实际中它是如何工作的。我们会写一个简略的图形系统在缓冲区中绘制像素。在多数主机和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_;
};
每一帧,游戏都会调用场景的draw方法。场景清空缓冲区并依次绘制所有的像素。他也提供了一个可以让显卡驱动访问到内部数据的getPixels()方法。
这看起来很直观,但如果我们就保持这样,会带来问题。问题在于显卡驱动可以在任意时刻调用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_;
};
现在Scene有了两个缓冲区,存储在buffers_数组中。我们不从这个数组里直接引用,而是通过其他两个指针成员,next_和current_。绘制的时候,我们通过next_绘制到下一个缓冲区。显卡驱动可以通过current_拿到当前的像素数据。
这样,显卡驱动就再也拿不到我们正在修改的缓冲区了。剩下的唯一一个容易出问题的地方就是,在绘制的时候调用了swap()方法。我们的swap方法简单地交换了next_和current_引用。下一次显卡驱动调用getBuffer()的时候,只会获得我们画完的那个缓冲区。不再会出现开裂和一些难看的瑕疵。
12、不只用在图形上
双缓冲解决的核心问题是被访问的状态同时在被修改。这里通常有两个原因。我们在前面的图形例子中已经说了一个——状态可以被其他线程或者中断的代码读取。
另一个原因是:代码在修改状态的同时又访问状态。这可能出现在很多地方。尤其是图形和AI这些需要很多交互的地方。双缓冲尤其重要。
13、人工非智能
让我们来创建一个行为系统,用于一个基于喜剧的游戏。这个游戏有一个舞台,很多演员跑在上面,狂欢作乐。这是我们的演员基类:
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(),因此有机会做一些事情。从玩家的角度,更希望所有的演员应该同时更新。
演员们之间可以交互,“交互”的意思是他们可以相互滚动对方。更新的时候,演员可以调用其他演员的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];
};
Stage让我们可以添加演员,并且提供一个update(),可以更新所有演员。在玩家看来所有演员同时移动,但是内部实现上,他们是依次更新的。
另一个需要关注的是,演员的slapped_的状态在更新之后会被马上重置。所以这个演员只能对slap反映一次。
继续,我们定义一个具体的子类——喜剧演员(Comedian)。这里很简单。他面向另外一个喜剧演员,如果他被滚动了,他就会滚动他所面向的那个演员。
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);
这个舞台会呈现出下图的样子。箭头指向这个演员面向的对象,数字是他们在舞台数组上的索引。
现在滚动Harry来驱动这些东西,然后看会发生什么:
harry->slap();
stage.update();
记得Stage的update()函数,依次更新每个演员。如果我们单步调试代码,我们会发现发生下面的事情:
Stage updates actor 0 (Harry)
Harry was slapped, so he slaps Baldy
Stage updates actor 1 (Baldy)
Baldy was slapped, so he slaps Chump
Stage updates actor 2 (Chump)
Chump was slapped, so he slaps Harry
Stage update ends
在一帧之内,我们最初给Harry的滚动传递到了所有的喜剧演员身上。现在,我们稍微加大一点复杂度,改变舞台数组中喜剧演员的位置,但是仍然按照原来的朝向。
舞台其他的代码不变,我们只是改变一下添加喜剧演员代码的顺序:
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
厄,完全不一样。问题也很明显。更新这些演员时,我们修改了他们的“slapped”状态,然后在同一次更新中读取这个状态。因此,先在update中改变状态,然后在同一个update中去影响下面的部分。
最终的结果是一个演员可能在同一次update中去响应滚动操作,也可能在下一帧,这取决于这两个演员在舞台上的顺序。这违反了我们的一个需求,就是演员要表现出并行运行的状态——不应该关心他们更新的顺序。
14、被缓冲的滚动
幸运的是,双缓冲模式可以帮助我们。这次不再维护两份整个的缓冲区对象,而是采用更合适的粒度:每一个演员的slaped状态:
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();
}
}
update()函数现在更新所有的演员,然后统一翻转他们的状态。结果是一帧里只能看到一个被slap演员的slap状态,而不再与他们在舞台上的顺序有关。只要玩家或者任意的外部代码调用,都是所有的演员在一帧中同时被更新。
15、设计决策
双缓冲模式很直观,上面的例子也基本覆盖了大多数你会遇到的情况。当实现这个模式时,有两个决策点你需要关注。
16、如何翻转缓冲区?
翻转操作是这个过程最重要的一步,因为当它发生的时候,我们必须锁住两个缓冲区的所有读写操作。为了得到最高的效率,我们希望越快越好。
(1)、交换缓冲区的指针:
这是我们图形例子中用到的,也是双缓冲图形中通用的解决方案。
1)、它很快。与缓冲区多大没有关系,这种交换只是简单的两个指针引用。很难再有比它更快更简单的了。
2)、外部代码不能保存这个指向缓冲区的指针。这是主要的局限性。我们没有真正地移动数据,本质上是我们定时通知外部代码去缓冲区的其他部分寻找数据,跟前面舞台例子中类似。这意味着外部的代码不能保存那个直接指向缓冲区的指针——因为他们过一会可能就会指向一个错误的位置。在一个显卡希望帧缓冲区是一个固定位置的系统中,这可能是个大问题。我们不会再选择这样的方式。
3)、当前缓冲区的数据是来自两帧前的,而不是前一帧的。连续的帧被写入两个交替的缓冲区中,而且他们直接也不存在数据拷贝,就像这样:
Frame 1 drawn on buffer A
Frame 2 drawn on buffer B
Frame 3 drawn on buffer A
...
你会发现当我们要写入第三帧数据时,缓冲区中的数据是来自第一帧,而不是更接近的第二帧。大多数情况下,没问题——我们通常在绘制之前清理掉当前的缓冲区。如果我们试图重用缓冲区中的某些数据,那就需要考虑到数据是按照帧序列的,这超出了我们的期望。
(2)、缓冲区之间复制数据:
如果我们不能重新指向其他缓冲区,那唯一能做的就是老老实实地从下一个缓冲区把数据拷贝到当前缓冲区。这就是我们前面喜剧的例子中用到的。在这个例子中,我们用这种方法,因为状态——只是一个布尔值——不会比复制一个指针消耗更大。
1)、下一个缓冲区的数据是唯一的份老数据。这就就是在两个缓冲区像乒乓球一样来回来去复制数据的好处。如果我们要访问上一个缓冲区的数据,这将是最接近的老数据。
2)、翻转操作会花费更多时间。这当然是最大的缺点。我们的切换操作现在意味着拷贝整块缓冲区的数据。如果缓冲区很大,就像帧缓冲区,你就会花更多时间。由于这个时间段既不能读也不能写,所以有比较大的局限性。
17、缓冲的粒度怎么选?
另外一个问题是如何组织缓冲区本身——是一块单独的数据,还是分布在对象中的属性?我们图形的例子用的是前者,演员那个是后者。
多数时候,你要缓冲的内容会自然的给你答案,但是也可以做一些变化。例如,我们的演员可以把它们的信息保存在一个信息块中,然后用他们的编号去索引。
(1)、如果缓冲区是整体的:
翻转起来比较简单。因为只有两个缓冲区,翻转一次就够了。如果你能通过改变指针而翻转那就可以只修改两个引用,而不用管缓冲区的大小。
(2)、如果是很多对象各自的一块数据:
翻转会比较慢。为了翻转我们要便利整个对象的集合并让每一个对象去翻转。在我们喜剧的例子中,它能够正确运行时因为我们清理了下一个状态——也就是每帧都去操作所有的状态缓冲。如果我们不想去管老状态缓冲,这里有一个优化办法,把对象中分散的状态变量组织成一块整体的缓冲区。这种“当前”和“下一个”指针概念,可以应用在我们的对象中,把他们转化成对象中的偏移,就像这样:
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_索引。这里聪明一点是swap()现在是静态函数——只需要执行一次,所有的演员就会翻转状态。
18、扩展阅读
你会发现双缓冲模式被几乎所有的图形API使用。如OpenGL的swqpBuffers(), Direct3D 有swapChains,还有微软的XNA framework 在它的endDraw()方法中翻转缓冲区。
加入GAD的核心用户QQ群:484290331,各类活动奖励任你拿,最新资讯任你读,众多教学任你免费学,如此好地方赶紧加入吧!另VR专属群:476511561,专业VR技术分享,专业导师指导为你答疑解惑,大型小型活动奖励等你拿,免费学习赚奖励的天地,欢迎你加入哟!