跳到內容

OpenGL 程式設計/現代 OpenGL 教程文字渲染 02

來自華夏公益教科書
使用紋理圖集最佳化文字渲染

在第一個文字渲染教程中,我們為繪製的每個字元上傳了一個新紋理到顯示卡。這當然非常浪費。如果我們可以將所有可能的字元影像永久儲存在顯示卡上,那會好得多。一種簡單的方法是為每個字元準備多個紋理,這樣我們只需在繪製四邊形時在紋理之間切換即可。但是,這意味著我們一次只能繪製一個四邊形,如果我們有大量的文字,那就意味著大量的 OpenGL 呼叫。一種稍微複雜的方法是使用單個、大型紋理來儲存所有字元,併為每個四邊形正確設定紋理座標,以便渲染正確的字元。這也稱為紋理圖集

建立紋理圖集

[編輯 | 編輯原始碼]

紋理圖集基本上是一個大型紋理,其中包含許多打包在一起的小影像。如果所有子影像都具有相同的大小,則建立緊密打包的圖集相當容易。但是,我們已經看到,FreeType 生成的字形影像大小差異很大。即使使用等寬字型!雖然有各種方法可以有效地打包一組任意大小的矩形,但本教程中將使用一種非常簡單的方法:我們將所有字元並排放在一行中。

但是,在我們能夠建立圖集本身之前,我們需要知道所有字形影像的總寬度和最高字形的高度。假設我們已經初始化了 FreeType,載入了字型,並設定了字型大小,這是一個簡單的操作

FT_GlyphSlot g = face->glyph;
int w = 0;
int h = 0;

for(int i = 32; i < 128; i++) {
  if(FT_Load_Char(face, i, FT_LOAD_RENDER)) {
    fprintf(stderr, "Loading character %c failed!\n", i);
    continue;
  }

  w += g->bitmap.width;
  h = std::max(h, g->bitmap.rows);
}

/* you might as well save this value as it is needed later on */
int atlas_width = w;

請記住,變數“g”只是一個為了節省一些輸入的快捷方式,本著這種精神,我們還使用了 max() 函式,因此你應該在原始碼頂部放置 #include <algorithm>。我們跳過前 32 個 ASCII 字元,因為它們只是控制程式碼。

現在我們知道了圖集的寬度和高度,我們可以為它建立一個空紋理

GLuint tex;
glActiveTexture(GL_TEXTURE0);
glGenTextures(1, &tex);
glBindTexture(GL_TEXTURE_2D, tex);
glPixelStorei(GL_UNPACK_ALIGNMENT, 1);

glTexImage2D(GL_TEXTURE_2D, 0, GL_RED, w, h, 0, GL_ALPHA, GL_UNSIGNED_BYTE, 0);

我們使用了一個空指標來告訴 OpenGL 我們將在稍後填充紋理的內容。同樣,不要忘記 GL_UNPACK_ALIGNMENT。現在我們準備將字形影像貼上到紋理圖集中。我們將使用非常方便的 glTexSubImage2D() 函式來實現這一點

int x = 0;

for(int i = 32; i < 128; i++) {
  if(FT_Load_Char(face, i, FT_LOAD_RENDER))
    continue;

  glTexSubImage2D(GL_TEXTURE_2D, 0, x, 0, g->bitmap.width, g->bitmap.rows, GL_ALPHA, GL_UNSIGNED_BYTE, g->bitmap.buffer);

  x += g->bitmap.width;
}

就是這樣,我們的紋理圖集完成了……除了我們應該嘗試記住可以在圖集中找到單個字元的位置。

快取字形度量和紋理偏移量

[編輯 | 編輯原始碼]

當我們構建頂點緩衝區時,我們需要知道要使用的正確紋理座標。最重要的是,我們需要知道 x 偏移量,其餘資訊可以在 FreeType 的 face->glyph 結構體中找到。但是,始終呼叫 FT_Load_Char 效率也不高,因此最好的方法是將渲染文字所需的所有資訊儲存在我們自己的快取中。由於我們只使用 ASCII 字元集,我們可以只建立一個數組來儲存所有 ASCII 字元的資訊

struct character_info {
  float ax; // advance.x
  float ay; // advance.y
  
  float bw; // bitmap.width;
  float bh; // bitmap.rows;
  
  float bl; // bitmap_left;
  float bt; // bitmap_top;
  
  float tx; // x offset of glyph in texture coordinates
} c[128];

在我們將字形影像上傳到紋理的 for 迴圈中,我們填充了這個陣列

  c[*p].ax = g->advance.x >> 6;
  c[*p].ay = g->advance.y >> 6;

  c[*p].bw = g->bitmap.width;
  c[*p].bh = g->bitmap.rows;

  c[*p].bl = g->bitmap_left;
  c[*p].bt = g->bitmap_top;

  c[*p].tx = (float)x / w;

雖然該結構體只包含 float 型別的成員,但你可以根據字形和圖集的最大大小,使用 (u)int16_t 甚至 (u)int8_t 來表示它們。由於我們正在處理 ASCII,我們也可以省略 g->advance.y 值的副本,因為它應該始終為 0。但是,如果你想超越 ASCII,最好不要做任何假設。

使用圖集渲染文字行

[編輯 | 編輯原始碼]

現在我們真正擁有了所有所需的資訊,我們可以構建一個頂點緩衝區,其中包含渲染完整文字行所需的所有資訊。讓我們將上一教程中的 render_text() 函式調整為使用紋理圖集

void render_text(const char *text, float x, float y, float sx, float sy) {
  struct point {
    GLfloat x;
    GLfloat y;
    GLfloat s;
    GLfloat t;
  } coords[6 * strlen(text)];

  int n = 0;

  for(const char *p = text; *p; p++) { 
    float x2 =  x + c[*p].bl * sx;
    float y2 = -y - c[*p].bt * sy;
    float w = c[*p].bw * sx;
    float h = c[*p].bh * sy;

    /* Advance the cursor to the start of the next character */
    x += c[*p].ax * sx;
    y += c[*p].ay * sy;

    /* Skip glyphs that have no pixels */
    if(!w || !h)
      continue;

    coords[n++] = (point){x2,     -y2    , c[*p].tx,                                            0};
    coords[n++] = (point){x2 + w, -y2    , c[*p].tx + c[*p].bw / atlas_width,   0};
    coords[n++] = (point){x2,     -y2 - h, c[*p].tx,                                          c[*p].bh / atlas_height}; //remember: each glyph occupies a different amount of vertical space
    coords[n++] = (point){x2 + w, -y2    , c[*p].tx + c[*p].bw / atlas_width,   0};
    coords[n++] = (point){x2,     -y2 - h, c[*p].tx,                                          c[*p].bh / atlas_height};
    coords[n++] = (point){x2 + w, -y2 - h, c[*p].tx + c[*p].bw / atlas_width,                 c[*p].bh / atlas_height};
  }

  glBufferData(GL_ARRAY_BUFFER, sizeof coords, coords, GL_DYNAMIC_DRAW);
  glDrawArrays(GL_TRIANGLES, 0, n);
}

首先,我們定義了一個結構體來儲存每個點的頂點和紋理座標,以便更容易編寫程式碼來填充所有值。我們需要為將要渲染的每個字元準備 6 個點,因為我們必須使用 GL_TRIANGLES 而不是 GL_TRIANGLE_STRIP 來能夠渲染單獨的四邊形。在迴圈中,我們執行與之前相同的計算,但使用快取的字形度量。我們添加了一個最佳化來跳過繪製寬度和/或高度為零的字形,例如空格字元。迴圈填充了我們的頂點緩衝區後,我們只需要將其上傳到顯示卡,並告訴它渲染所有三角形即可。

  • 建立一個 struct atlas,它儲存特定字型和字型大小的紋理和快取,這樣你就不會再有任何全域性變數。使你可以將此類結構體的指標傳遞給 render_text()
  • 如果你繪製未對齊的文字,你應該會看到比上一個教程更多的渲染偽像。你能解釋一下為什麼會發生這種情況嗎?你將如何防止這種情況發生?
  • 使用索引緩衝區物件,這樣你只需要在頂點緩衝區中為每個字元準備 4 個點。這是一個值得的最佳化嗎?
  • 由於我們正在處理畫素對齊的座標,我們實際上不需要浮點座標。OpenGL 還支援 16 位整數座標 (GL_SHORT)。將字形度量快取和頂點緩衝區轉換為使用它,並根據需要更改著色器。
  • 如果你有一個帶有 GL_SHORT 座標的 VBO,使用 IBO 仍然值得嗎?
  • 紋理圖集技術也有助於輕鬆快取 VBO(和 IBO),這樣你就可以在文字沒有改變的情況下無需重新計算任何內容。文字只有最後部分發生變化也很常見。你將如何最佳化它?

故障排除

[編輯 | 編輯原始碼]

如果你在正確渲染時遇到問題,請檢視以下列表。

  • 在較新的 OpenGL 版本中,GL_ALPHA 值已不建議在 glTexImage2D 函式中使用。將其替換為 GL_RED 並修改著色器以讀取紅色通道來修復此問題。有關有效值的列表,請參閱glTexImage2D 文件


< OpenGL 程式設計

瀏覽和下載完整程式碼
華夏公益教科書