Skip to content

Latest commit

 

History

History
454 lines (367 loc) · 28.7 KB

03.1-Double Buffer.md

File metadata and controls

454 lines (367 loc) · 28.7 KB

双缓冲模式

目的

进行一系列序列化操作来表现出瞬发性或同步性。

动机

计算机的心脏里藏着野兽般的序列化处理能力。其力量源于它们将庞大任务分解成能够被逐一处理的细小步骤的本领。尽管通常我们的用户需要看到的是问题能够即刻被一步搞定,或者多个任务同时被执行。

注解:虽然线程技术和多核架构在不断进步,但即便在多核环境下,也仅有少数操作能真正同步的执行。

举个典型的例子,每个游戏引擎都必须处理的问题--渲染。当引擎渲染出用户所见的世界时,它是分块逐步来完成渲染的:远处的山峰,起伏的丘陵,树木,这些部分被逐个轮流渲染。假如用户也像这样逐步地观察视窗的渲染过程,那么这个连贯一致的游戏世界的幻像将会破碎。场景必须快速而平滑地进行更新,显示一系列完整的帧,而每帧都应当瞬间显示出来。

双缓冲模式解决了上述问题,但为理解其原理,我们首先需要回顾一下计算机是如何进行图形显示的。

##计算机图形系统是如何工作的(概述) 诸如计算机显示器的显示设备在每一时刻仅绘制一个像素。显示设备从左至右地扫描屏幕第一行的每个像素,并如此从上至下地扫描屏幕上的每一行。直到扫描至屏幕的右下角,它将重定位至屏幕的左上角并如前述那样地重复扫描屏幕。这一扫描过程是如此地快速(大概每秒60次),以至于我们的眼睛无法察觉这一过程。于我们而言,扫描的结果就是屏幕上一块彩色像素组成的静态区域,即一张图片。

注解: 这样的阐述不太妥当--它过于简单了。假如你从事底层硬件开发我想你大概已经觉得烦了,请肆意跳过后面的部分。凭你所知已足以理解本章余下的内容。但假如你并非这样的高人,那么在此我的目的是给予你足够的背景知识以便你能理解我们随后要讨论的设计模式。

你可以将上述过程想象成一根细小的软管在向显示区域不断喷洒出像素。各类颜色像素到达软管的末端,软管将它们喷射到显示区域中,每次往每个像素上喷洒一点。那么它如何知道哪个像素该往哪喷呢?

在多数计算机中答案是:它从_帧缓冲区_(framebuffer)中获知这些信息。帧缓冲区是一个内存中存储着像素的数组(它是RAM中的一个块,其中每两个字节表示一个像素的色彩)。当软管往显示区域喷洒时,它从这个数组中读取颜色值,每次读取1字节。

注解: 字节值与颜色之间的特殊映射关系是通过系统中的_像素格式_以及_色彩深度_来描述的。在当今的多数游戏机中,每个像素占32位:红,绿,蓝色彩通道各占8位,剩余的8位则保留作,其他各种用途。

基本上讲,为了让游戏在屏幕上显示出来,我们要做的只是往这个数组里写东西。我们熬夜折腾出来的那些先进图形算法,其根本都只是在往帧缓冲区里设置字节值。但这里有个小问题。

前面我说计算机的处理是序列化的。假设计算机正在执行我们的一段渲染代码,我们便不希望计算机同时在做其他不相干的事。这几乎是对的,然而在我们的程序运行过程中间_确实会_穿插着许多其他的事情:比如当我们的游戏在运行时,显示设备会从帧缓存中读取内存中的像素信息。这就为我们带来了问题。

比如我们希望在屏幕上显示一张笑脸。我们的程序开始循环访问帧缓存并对像素进行渲染。出乎我们意料的是,显卡正是在我们往帧缓存中写入数据的同时进行数据读取的。随着它扫描过那些我们已经写入的数据,笑脸便开始在屏幕上浮现,但它渐渐超过我们的写入速度而访问了帧缓存中那些未写入数据的部分——_悲惨_的结局,屏幕上留下了一个半成品,这是个能看得一清二楚的丑陋BUG。

注解: 我们在显卡设备开始从帧缓存读取数据的同时进行像素数据的写入(图16.1)。最终显卡赶上并超过了渲染器并访问了我们尚未写入数据的帧缓存区域(图16.2)。我们结束绘制(图16.3)时,显卡设备错过了那些读取后才写入的数据。结果用户看到的是渲染的半成品(图16.4)。我称它是“哭丧脸”——笑脸的下半边像是被撕掉了一样。

这就是我们需要本设计模式的原因。我们的程序一次只渲染一个像素,同时我们要求显示器一次性显示所有的像素——可能这一帧看不到任何东西,但下一帧显示的就是完整的笑脸。双缓冲模式解决了这一问题。下面我会以类比的形式来阐述。

##第一幕,第一场 设想我们的用户正在观看我们创作的一场表演。当第一个场景谢幕后第二个场景跟着上映,这时候我们需要切换布景。如果我们在场景后台控制舞台管理设备直接开始收起场景道具,那么场景在视觉上的连续性会被破坏。我们可以在收拾场景的同时将灯光变暗(当然,这也正是影剧院所做的),而观众们依然知道黑暗中戏剧_的某些事件_仍在继续。我们希望在剧幕之间不会产生时间上的间隙。

在空间允许的情况下,我们想到了这个聪明的办法:我们建立两个舞台以便它们都能为观众所见。它们各有各的一套灯光。我们称其为A舞台和B舞台。场景1正在A舞台上上演,同时舞台B正处在黑暗中并正由场景后台进行着场景2的准备。一旦场景1结束,我们就关掉A舞台的灯光并将灯光转移到B舞台,观众们便立即聚焦到新舞台并看到了第二幕场景上映。

与此同时,我们的场景后台正在清理此刻已经暗下的舞台A,它清理场景1并为场景3做准备。一旦场景2结束,我们再将光线聚焦到A舞台上。我们在整场表演过程中重复上述过程,将黑暗中的舞台作为工作区来为下个场景做准备。每次场景切换,我们只是将灯光在两个舞台之间来回切换。我们的观众于是就看到了衔接流畅而无缝的场景转换。他们从不会看到舞台的后台。

注解:借助单面镜以及其他一些巧妙的布局,实际上你能够在_同一个舞台_进行场景之间的无缝切换。当灯光转移时,观众们可能会聚焦到另一个舞台上,但他们并不一定要转移视线。如何做到这一点就给读者留作练习吧。

##回到图形上 上面就是双缓冲模式的工作原理,你所见到的任何一款游戏其渲染系统中都重复着这样的过程。我们使用_两个_帧缓存而非一个。双缓冲中的一个缓存用于展示当前帧,即上述例子中的A舞台。它就是显示硬件读取像素数据来进行渲染的地方,GPU可以随时对其进行任意数据量的扫描。

注解:然而_并非所有_的游戏和控制台都这么做。早前比较简单的控制台游戏受到内存的局限,小心翼翼地将渲染与显示屏刷新操作进行同步来取代双缓冲,这可是要技巧的。

于此同时,我们的渲染代码正在_另一个_帧缓冲区中写入数据,它就是我们处于黑暗中的B舞台。当渲染代码完成场景2的绘制时,它通过_交换_两个缓冲区来”切换舞台光线”。这使得显卡驱动开始从第一个缓冲区转向第二个缓冲区以读取其数据进行渲染。只要它掌握好时机在每次刷新显示结束时进行切换,我们就不会看到任何衔接的裂隙,且整个场景能一次性在瞬间显示出来。

这时候,旧的帧缓冲变得可用了,我们就开始往它的内存区域渲染入下一帧。这真棒!

双缓冲模式

定义一个__缓冲区__类来封装一个__缓冲区__ :一块能被修改的状态区域。这块缓冲区能被逐步地修改,但我们希望任何外部的代码将对该缓冲区的修改都视为原子操作。为实现这一点,此类中维护__两个__缓冲区实例:当前缓冲区后台缓冲区

当要从缓冲区中_读取_信息时,总是_从当前_缓冲区读取。当要往缓冲区中_写入_数据时,则总_在后台_缓冲区上进行。当改动完成后,则执行__“交换”__操作来将当前缓冲区与后台缓冲区进行瞬时的交换,以便让新的缓冲区为我们所见,同时刚被换下来的当前缓冲区则成为现在的后台缓冲区以供复用。

使用情境

这是个到需要时你自然会想起的设计模式之一。假如你的系统不支持双缓冲,那么使用此模式很可能会出现视觉错误(比如会出现“撕裂”现象),或者显示将表现出异常。但是说“需要的时候你自然会想起”会让你无所适从,更准确地说,当下面这些条件都成立时,适用双缓冲模式:

- 我们需要维护一些被逐步改变着的的状态量。 - 同个状态可能会在其被修改的同时被访问到。 - 我们希望改变状态的代码块对正在访问这些状态的外部代码透明。 - 我们希望能够读取到这些状态,而无需在其被写入时等待。

使用须知

不同于其他更大架构的设计模式,双缓冲模式的实现处于较底层。因此,它对代码库的影响较少--甚至多数游戏都不会在意这些差别。当然,下面这些附加说明还是值得一提的。

##交换操作本身是耗时的 双缓冲模式需要在状态写入完成后进行一个_交换缓冲区_的动作。这个操作必须是原子性的:也就是说任何代码都无法在这个操作其间对缓冲区内的_任何_一块状态进行访问。通常这个交换过程和分配一个指针的速度差不多,但万一交换花去了比修改初始状态更多的时间,那这模式就毫无助益了。

##我们必须要有两个缓冲区 使用此模式的另一结果是导致内存占用率增加。正如其名,此模式要求你在任何时刻都维护着_两份_存储着状态的内存区域。在内存受限的硬件上,这可是个很苛刻的要求。假如你无法分配出两份内存,你就必须想其他办法来避免你的状态在修改时被访问。

示例

说完理论,让我们来结合实践,看看它是如何工作的。我们将写一个极其简单的图形系统以供我们在帧缓存上绘制像素。在多数控制台和 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_;
};

注解:具体来说,它画出了这样一幅杰作: 

游戏在每帧通知场景进行绘制。场景清理缓冲区接着绘制一系列像素,一次一个。它也通过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_;
};

现在Scene拥有两个缓冲区,它们被置于buffers_数组中。我们并不从数组中直接引用它们,而是通过next_current_这两个指针成员来指向数组。当我们绘图时,我们往next 这个缓冲区(通过next_访问)里绘制,而当显卡驱动需要获取像素信息时,它总是从_另一个_current_所指向的current缓冲区中获取。

由此,显卡驱动将永远不会访问到我们所正在进行处理的缓冲区。剩下的问题就在于在场景完成帧绘制后,对swap()方法的调用。它简单地通过交换next_current_这两个指针的指向来交换两个缓冲区。当下一次显卡驱动调用getBuffer()函数时,它将获取到我们刚刚完成绘制的那块新的缓冲区,并将其内容绘制到屏幕上。再也不会有图形撕裂和不美观的问题了。

##并非只针对图形 双缓冲模式所解决的核心问题在于对状态同时进行修改与访问的冲突。造成此问题的原因通常有两个,我们已经通过上述图形示例描述了第一种情况--状态直接被另一个线程或中断的代码所直接访问。

而另一种情况同样很常见:_进行状态修改_的代码访问到了其正在修改的那个状态。这会在很多地方发生:尤其是实体的AI和物理部分,在它与其他实体进行交互时会发生这样的情况,双缓冲模式往往能在此能奏效。

##没智商的AI 假设我们在为一个基于打斗漫画的游戏中的所有实体构建行为系统。游戏包含一个舞台,许多角色在其中追逐打闹。下面是我们的基础角色类:

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

游戏需要在每一帧对演员实例调用update()以让其进行自身的处理。严格说来,从用户的角度,所有的角色必须看起来是同步地进行更新

注解:这是一个[Update Method](./03.3-Update Method.md)的例子

演员也可以通过“相互作用”与其他角色进行交互,这里特指“他们可以互相扇对方巴掌”。当更新时,角色可以对其他角色调用自身的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()方法来更新所有角色。对于用户而言,角色开始同步地各自移动,但从内部看,一个时刻仅有一个角色被更新。

另一点需要注意的是,每个角色“被扇巴掌”的状态在其更新结束后立即被清空重置。这是为了确保一个角色每次只会对受到的一个巴掌作出响应。

接下来,我们来为角色定义一个具体的子类。我们的滑稽演员很简单,它面对一个指定角色,不论谁给了它一巴掌,它就冲着这个角色扇巴掌。

class Comedian : public Actor
{
    public:  void face(Actor* actor) { facing_ = actor; } 
    virtual void update()  
    {    
        if (wasSlapped()) facing_->slap();  
    }

private:  
    Actor* facing_;
};

现在,让我们往舞台里放一些滑稽演员来看看会发生什么。我们对三个演员进行恰当的设置,使他们每个都面对着下一个,而最后一个面向第一个,形成一个圈。(译者注: 即构成一个小的单向循环链表, 每个演员中的facing_成员即为链表中节点的next指针)

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

现在该舞台的布局如下图所示。箭头指明了角色所面朝的另一个角色,而数字表示角色在舞台的actors_数组中的索引号。 现在我们往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

哦不!完全不一样了。问题很明显,当我们更新角色时,我们修改它们的“被掴巴掌”状态,我们也在修改的同时_读取_这些状态。因此在_同一次_舞台更新循环中,状态的修改仅仅会影响到在其后更新的那些角色。

注解: 假如你继续更新舞台,你将看到扇巴掌的动作渐渐在角色之间传递,每帧传递一个。在第一帧,Harry扇了Baldy一巴掌,下一帧Baldy扇了Chump一巴掌,如此递推。

最终的结果是某个角色可能不会在被扇巴掌的_这一帧_做出反应也不会在_下一帧_做出反应——这完全取决于两个角色在舞台中的顺序。这违背了我们对角色的要求:我们希望它们平行地运转;而他们在某帧更新中的顺序是不应该对结果产生影响的。

##缓存这些巴掌 幸运的是,我们的双缓冲模式能帮上忙。这一次,我们将缓存一系列粒度更恰当的数据:缓存每个角色的“被掴”状态,而不是先前的那两个庞大的缓冲区对象:

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

现在每个角色有两个状态(currentSlapped_以及nextSlapped_)而不是一个slapped_。正如先前图形的例子一样,当前的状态用于读取,下一个状态用于写入。

reset()函数被swap()方法所替换。现在,在清除交换的状态之前,角色先将下一状态复制到当前状态中,使其成为当前状态。这里还需要在Stage中进行一些小改动:

class Stage
{  
    void update()  
    {    
        for (int i = 0; i < NUM_ACTORS; i++)   
        {      
            actors_[i]->update();
        }    
        
        for (int i = 0; i < NUM_ACTORS; i++)   
        {      
            actors_[i]->swap();    
        }  
    }  
    
    // Previous Stage code...
};

现在update()函数更新所有的角色_接着_对他们的所有状态进行交换。这样的最终结果是,每个角色在其被扇巴掌那帧的_下一帧_中仅会看到一个巴掌。这样一来,这些角色就会表现一致而不受它们在舞台上顺序的影响。对于用户和外部的代码而言,这些角色在一帧之内就是同步更新的。

设计决策

双缓冲模式很直白,我们上面所看到的例子也几乎将你可能遇到的不同情况都涵盖到了。当实现这种模式时主要会有如下两点的讨论:

##缓冲区如何进行交换? 交换缓冲区的操作是整个过程最关键的一步,因为在这一过程中我们必须封锁对两个缓冲区所有的读写操作。为达到最优性能,我们希望这个过程越快越好。

交换指向两个缓冲区的指针:

    这是我们图形例子中的做法,也是处理图形双缓冲最通用的解决方案。

    - 这很快。     

    - 它无视缓冲区的大小,交换操作只是两个指针分配的动作。没办法让这个过程更加简化或更快了。     

    - 外部代码无法存储指向某块缓冲区的持久化指针。这是该方法主要的约束。因为我们并没有真正地移动_数据_,我们实际上做的是周期性地告诉其他代码库去另外一些地方找缓冲区,就像我们最初所比喻的舞台那样。这意味着其他代码库无法直接存储指向某个缓冲区内数据的指针,因为过一会儿它就可能指向错误的缓冲区数据了。

    - 这对于那些显卡希望帧缓冲区在内存中固定地址的系统来说尤其会造成麻烦。如果是那样,我们就不能采用这种办法。

    - 缓冲区中现存的数据会来自两帧之前而不是上一帧的。连续几帧里在交替的两个缓冲区中进行绘制而不在它们之间进行数据复制,如下:

    - 你将会注意到当我们要绘制第三帧时,在缓冲区中的数据来自_第一帧_的,而不是来自最近的_第二帧_。在多数情况下,这并不是问题——我们往往在绘制前会清理整个缓冲区。但假如我们希望对缓冲区现存的某些数据进行复用,那么就必须考虑到那些数据是比我们所预期的更提早一帧。

注解: 双缓冲一个经典的应用是处理动态模糊。当前帧与先前渲染帧的一部分进行混合,以便让产生的图像更接近于真实摄像机拍摄产生的效果。

在两个缓冲区之间进行数据的拷贝:     

    假如我们无法对缓冲区进行指针重定向,那么唯一的办法就是将数据从后台缓冲区实实在在地拷贝到当前缓冲区。这就是我们在打斗喜剧里所做的。在这一情况下,我们选择此方法是因为其缓冲区仅仅是一个简单的布尔值标志位——它并不会比复制指向缓冲区的指针花去更长的时间。

    位于后台缓冲区里的数据与当前的数据就只差一帧时间。这是拷贝数据方法的优点,它就像打乒乓球那样一来一回通过两个缓冲区的翻转来推进画面。假如我们需要访问先前缓冲区的数据,此方法会提供更加实时的数据以供我们使用。

    - 交换操作可能会花去更多时间。这当然是个大缺点。这里的交换就意味着拷贝内存中的整个缓冲区数据块。假如缓冲区很大,比如是一整个帧缓冲区,那么进行交换就会很明显地花去一整块时间。在交换期间无法对_任何一个_缓冲区进行读写操作,这是个很大的局限。

##缓冲区的粒度如何?

另一个问题在于缓冲区其自身是如何组织的:它是单个庞大数据块还是分布在某个集合里的每个对象之中?我们在图形的例子中使用了前一形式而演员类中使用了后者。 多数时候,你所要缓存的内容将会告诉你答案,当然也有调整的空间。例如,我们的演员也都可以将他们的信息集中存储在一个独立的信息块中,并让演员们通过他们的索引指向其中各自的状态。

假如缓冲区是单个整体          

交换操作很简单,因为全局只有一对缓冲区,只需要进行一次交换操作。假如你通过交换指针来交换缓冲区,那么你就可以交换整个缓冲区而无视其大小,只是两次指针分配而已。

假如许多对象都持有一块数据

    - 交换较慢。  

    - 为实现交换,我们需要遍历对象集合并通知每个对象进行交换。     

    - 在我们的打斗喜剧中,这是没啥问题的,因为我们总需要清理后台“被扇巴掌”的状态——每帧都必须访问到每个对象所缓存的状态。假如我们不需要访问缓存的状态,那么我们就可以对其进行优化来使其达到与使用单块大缓冲区存储一系列对象状态一样的效率。 ——此时的办法就是使用“当前”和“下一个”指针的概念并将它们作为对象内部的成员(译者注:类似建立链表)。如下:

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()现在是一个静态方法——只需要调用一次,则每个演员的状态都会被交换。  

其它参考

- 你几乎能在任何一个图形API中找到双缓冲模式的应用。例如,OpenGL 中的swapBuffers()函数,Direct3D 中的“swap chains”,微软 XNA 框架在endDraw()方法中也进行着帧缓冲区的交换。