天天看點

雙緩沖技術講解

筆者介紹:姜雪偉,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都使用了雙緩存技術,在這裡給讀者隻是揭示一下其實作原理,加深讀者對于雙緩存的認識和了解。

繼續閱讀