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

很有可能在某個時刻您會想要使用 OpenGL 繪製文字。這似乎是任何繪圖 API 的一項非常基本的功能,但您不會在 OpenGL 中找到任何處理文字的函式。這是有充分理由的:文字比您想象的要複雜得多。如果您是美國人並使用等寬字型,那麼生活很簡單。您不必考慮超過 128 個可能的字元,而且它們的大小都相同。如果您有等比例寬度字型,事情就已經變得更加困難了。如果您是歐洲人,那麼 256 個字元可能足以滿足一種語言,但要表示所有歐洲語言已經是不可能的。如果您包含世界其他地區,那麼您需要超過 65536(16 位)個字元,並且文字可能需要從右到左或從上到下渲染,並且您可能需要使用除將字元一個接一個地繪製在螢幕上的其他技術才能生成可理解的內容。與文字渲染相關,您可能還想渲染數學方程式、費曼圖、棋盤圖或樂譜。我希望您現在已經確信文字渲染是一個非常高階的函式,在像 OpenGL 這樣的低階圖形 API 中沒有位置。
也就是說,我們仍然希望使用 OpenGL 繪製文字。有各種方法可以做到這一點,包括
- 使用 glDrawPixels() 將文字直接繪製到幀緩衝區(在 OpenGL ES 2.0 中不可用)。
- 使用 GL_LINES 繪製字母形狀。
- 使用 GL_TRIANGLES 繪製填充的字母形狀。
- 使用字母的 3d 網格繪製真實的 3D 字母。
- 從字形紋理庫中將每個字形繪製為紋理化的四邊形
- 使用 CPU 將文字繪製到類似於經典 2d 文字渲染的紋理上,然後將該紋理投影到 3d 空間中的四邊形上。
- 從字形的向量紋理庫中將每個字形繪製為紋理化的四邊形。
在本教程中,我們將從使用每個字母一個紋理化的四邊形渲染非常簡單的(US-ASCII)文字開始,或者,用字型術語來說,“字形”。這種技術非常靈活,如果您能夠正確快取紋理,它也是渲染文字最快的方法之一。稍加註意,文字的視覺質量與瀏覽器或文字處理器渲染的文字一樣好。
如果使用某種形式的向量紋理擴充套件此技術,例如 Alpha Tested Magnification[1],則在任意變換後結果可以保持清晰。
為了繪製文字,我們需要某種方法來讀取字型,並將其轉換為我們可以與 OpenGL 一起使用的格式。許多作業系統都有標準的方法來讀取字型,但也有許多庫可以做到這一點。一個非常好的、眾所周知的、跨平臺的庫是 FreeType。它支援許多字型格式,包括 TrueType 和 OpenType。
使用 FreeType,您可以查詢字元、查詢其尺寸,並瞭解如何或多或少正確地定位它們;最重要的是,它為您提供了任何字元的灰度影像。這正是我們將在本教程中使用的文字繪製方法所需的功能。
雖然 FreeType 允許您訪問字型資料,但它不是文字佈局引擎。這意味著它不會為您渲染整行文字或段落。它也不能自動渲染 變音符號,自動使用 連字 或複製其他 複雜排版功能。如果您需要此功能,您應該使用文字佈局庫,例如 Pango,並一次繪製整個文字而不是一個字元。這可能會使用更多記憶體,並且在動態更改繪製文字時肯定會更慢。
使用 FreeType 非常簡單。以下兩行應新增到原始碼的頂部以包含正確的標頭檔案
#include <ft2build.h>
#include FT_FREETYPE_H
在使用任何其他 FreeType 函式之前,我們需要初始化庫
FT_Library ft;
if(FT_Init_FreeType(&ft)) {
fprintf(stderr, "Could not init freetype library\n");
return 1;
}
術語 字型 可以有不同的含義,但通常我們認為“Times New Roman”或“Helvetica”是字型。我們還在常規、粗體、斜體和其他樣式之間進行區分,因此“Helvetica Bold”與“Helvetica Italic”是不同的字型。如果您使用 FreeType 庫,則必須指定包含要使用其渲染文字的字型的檔案的完整檔名。在 FreeType 中,這稱為“face”。例如,要從當前目錄載入常規 FreeSans 字型,我們使用
FT_Face face;
if(FT_New_Face(ft, "FreeSans.ttf", 0, &face)) {
fprintf(stderr, "Could not open font\n");
return 1;
}
載入 face 後,我們基本上只有一個可以調整的引數,那就是字型大小。要將其設定為 48 畫素的高度,我們使用
FT_Set_Pixel_Sizes(face, 0, 48);
face 本質上是 字形 的集合。字形通常是單個字元,但它也可以是變音符號或連字。字型還可以包含多個相同字元的字形,提供替代渲染。(例如,請參閱出色字型 Linux Libertine 的功能列表。)即使 Unicode 字元 也並非一定與字型字形一一對應。但是,我們將忽略所有這些複雜性,並專注於良好的舊 ASCII 字元集。例如,要從字型中獲取字元“X”的字形,我們使用以下程式碼
if(FT_Load_Char(face, 'X', FT_LOAD_RENDER)) {
fprintf(stderr, "Could not load character 'X'\n");
return 1;
}
FT_Load_Char() 函式會將該字元的所有資訊填充到 face 的“字形槽”中,可以透過 face->glyph 訪問。因為我們指定了 FT_LOAD_RENDER,所以 FreeType 也會建立一個可以透過 face->glyph->bitmap 訪問的 8 位灰度影像。因為一直寫 face->glyph 很麻煩,而且因為指標 face->glyph 永遠不會改變,所以我們將定義以下變數作為快捷方式
FT_GlyphSlot g = face->glyph;
在本教程中,我們將使用以下資訊
- g->bitmap.buffer
- 指向字形的 8 位灰度影像的指標,以先前選擇的字型大小渲染。
- g->bitmap.width
- 點陣圖的寬度,以畫素為單位。
- g->bitmap.rows
- 點陣圖的高度,以畫素為單位。
- g->bitmap_left
- 相對於游標的水平位置,以畫素為單位。
- g->bitmap_top
- 相對於基線的垂直位置,以畫素為單位。
- g->advance.x
- 下一個字元水平移動游標的距離,以 1/64 畫素為單位。
- g->advance.y
- 下一個字元垂直移動游標的距離,以 1/64 畫素為單位,幾乎總是 0。
為什麼所有這些值?原因是並非每個字形的大小都相同。FreeType 渲染一個正好包含字元可見部分的影像。這意味著句點“.” 只有一個非常小的點陣圖,“X”將有一個大的點陣圖。這就是為什麼瞭解點陣圖的寬度和高度很重要。逗號“,”和撇號“'”可能被渲染為相同的點陣圖,但相對於基線的位置卻大不相同。“X”字元從基線開始,但延伸到其上方的很高位置,而“p”字元沒有那麼高,但向下延伸到基線以下。這些事情使得有必要知道點陣圖相對於游標和基線的偏移量。此外,字元的可見大小不一定告訴您下一個字元移動游標的距離。例如,想想空格字元!
對於文字渲染,我們通常可以滿足於非常基本的著色器。由於文字基本上是二維的,因此我們可以對頂點使用屬性 vec2,對紋理座標使用另一個屬性 vec2。但也可以將頂點和紋理座標組合成一個四維向量,並讓頂點著色器將其分成兩部分
#version 120
attribute vec4 coord;
varying vec2 texcoord;
void main(void) {
gl_Position = vec4(coord.xy, 0, 1);
texcoord = coord.zw;
}
雖然可能不直觀,但繪製文字的最佳方法是使用僅包含 Alpha 值的紋理。RGB 顏色本身對於所有畫素都設定為相同的值。當 Alpha 值為 1(不透明)時,繪製字型顏色。當它為 0(透明)時,繪製背景顏色。當 Alpha 值在 0 和 1 之間時,允許背景顏色與字型顏色混合。片段著色器如下所示
#version 120
varying vec2 texcoord;
uniform sampler2D tex;
uniform vec4 color;
void main(void) {
gl_FragColor = vec4(1, 1, 1, texture2D(tex, texcoord).r) * color;
}
此片段著色器允許我們渲染透明文字,應與混合結合使用
glEnable(GL_BLEND);
glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);
在我們開始渲染文字之前,還有一些事情需要初始化。首先,我們將使用單個紋理物件來渲染所有字形
GLuint tex;
glActiveTexture(GL_TEXTURE0);
glGenTextures(1, &tex);
glBindTexture(GL_TEXTURE_2D, tex);
glUniform1i(uniform_tex, 0);
為了防止字元沒有精確地渲染在畫素邊界上時出現某些偽影,我們應該在邊緣處鉗位紋理,並啟用線性插值
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
停用 OpenGL 用於上傳紋理和其他資料的預設 4 位元組對齊限制也非常重要。通常情況下,您不會受到此限制的影響,因為大多數紋理的寬度是 4 的倍數,和/或每個畫素使用 4 個位元組。但是,字形影像採用 1 位元組灰度格式,並且可以具有任何可能的寬度。為了確保沒有對齊限制,我們必須使用以下程式碼行
glPixelStorei(GL_UNPACK_ALIGNMENT, 1);
我們還需要為組合的頂點和紋理座標設定一個頂點緩衝物件
GLuint vbo;
glGenBuffers(1, &vbo);
glEnableVertexAttribArray(attribute_coord);
glBindBuffer(GL_ARRAY_BUFFER, vbo);
glVertexAttribPointer(attribute_coord, 4, GL_FLOAT, GL_FALSE, 0, 0);
現在我們已經準備好渲染一行文字。我們將使用的步驟很簡單。我們從某個基線(垂直)和游標(水平)位置開始,載入第一個字元,將其作為紋理上傳,在距起始位置的正確偏移量處繪製它,然後將游標移動到下一個位置。我們對該行中的所有字元重複此操作。
void render_text(const char *text, float x, float y, float sx, float sy) {
const char *p;
for(p = text; *p; p++) {
if(FT_Load_Char(face, *p, FT_LOAD_RENDER))
continue;
glTexImage2D(
GL_TEXTURE_2D,
0,
GL_RED,
g->bitmap.width,
g->bitmap.rows,
0,
GL_RED,
GL_UNSIGNED_BYTE,
g->bitmap.buffer
);
float x2 = x + g->bitmap_left * sx;
float y2 = -y - g->bitmap_top * sy;
float w = g->bitmap.width * sx;
float h = g->bitmap.rows * sy;
GLfloat box[4][4] = {
{x2, -y2 , 0, 0},
{x2 + w, -y2 , 1, 0},
{x2, -y2 - h, 0, 1},
{x2 + w, -y2 - h, 1, 1},
};
glBufferData(GL_ARRAY_BUFFER, sizeof box, box, GL_DYNAMIC_DRAW);
glDrawArrays(GL_TRIANGLE_STRIP, 0, 4);
x += (g->advance.x/64) * sx;
y += (g->advance.y/64) * sy;
}
}
函式 render_text() 接受 5 個引數:要渲染的字串、x 和 y 起始座標以及 x 和 y 縮放參數。最後兩個應選擇使得一個字形畫素對應於一個螢幕畫素。讓我們看看繪製整個螢幕的 display() 函式
void display() {
glClearColor(1, 1, 1, 1);
glClear(GL_COLOR_BUFFER_BIT);
GLfloat black[4] = {0, 0, 0, 1};
glUniform4fv(uniform_color, 1, black);
float sx = 2.0 / glutGet(GLUT_WINDOW_WIDTH);
float sy = 2.0 / glutGet(GLUT_WINDOW_HEIGHT);
render_text("The Quick Brown Fox Jumps Over The Lazy Dog",
-1 + 8 * sx, 1 - 50 * sy, sx, sy);
render_text("The Misaligned Fox Jumps Over The Lazy Dog",
-1 + 8.5 * sx, 1 - 100.5 * sy, sx, sy);
glutSwapBuffers();
}
我們首先將螢幕清除為白色,並將字型顏色設定為黑色。由於我們沒有使用任何變換矩陣,因此我們可以簡單地透過將 2 除以螢幕的寬度和高度來計算縮放因子。第一行(一個眾所周知的Pangram)完全對齊到畫素座標。第二行故意在每個方向上錯位半個畫素。差異很明顯;第二行看起來更模糊,並且可以看到一些難看的偽影。
您可能天真地認為,最好擁有比繪製時大兩倍或更多倍的紋理,以便 OpenGL 進行抗鋸齒。除非您使用多重取樣或 FSAA,否則不會發生這種情況。始終最好讓 FreeType 以正確的尺寸渲染字型,並將其正確對齊渲染。為了說明這一點,讓我們繪製縮小 2 倍和 4 倍的 48 點字型,並將其與“未縮放”的 24 點和 12 點字型大小進行比較。將以下內容新增到 display() 函式中
FT_Set_Pixel_Sizes(face, 0, 48);
render_text("The Small Texture Scaled Fox Jumps Over The Lazy Dog",
-1 + 8 * sx, 1 - 175 * sy, sx * 0.5, sy * 0.5);
FT_Set_Pixel_Sizes(face, 0, 24);
render_text("The Small Font Sized Fox Jumps Over The Lazy Dog",
-1 + 8 * sx, 1 - 200 * sy, sx, sy);
FT_Set_Pixel_Sizes(face, 0, 48);
render_text("The Tiny Texture Scaled Fox Jumps Over The Lazy Dog",
-1 + 8 * sx, 1 - 235 * sy, sx * 0.25, sy * 0.25);
FT_Set_Pixel_Sizes(face, 0, 12);
render_text("The Tiny Font Sized Fox Jumps Over The Lazy Dog",
-1 + 8 * sx, 1 - 250 * sy, sx, sy);
您應該會看到,儘管使用了 OpenGL 的線性紋理插值,但縮小文字的質量仍然低於未縮小文字的質量。這有幾個原因。首先,透過縮放文字,您讓 OpenGL 對已經進行抗鋸齒的字形影像進行抗鋸齒。其次,線性紋理插值最多使用四個紋理元素的加權平均值,實際上與計算紋理中 2x2 或 4x4 區域的平均值不同。最後但並非最不重要的一點是,FreeType 庫預設會應用提示以提高字元的對比度。當提示的字形影像的畫素沒有一對一地對映到螢幕畫素時,提示的效果就會丟失。
渲染彩色和/或透明文字很容易,我們只需將統一顏色更改為我們喜歡的顏色即可
FT_Set_Pixel_Sizes(face, 0, 48);
render_text("The Solid Black Fox Jumps Over The Lazy Dog",
-1 + 8 * sx, 1 - 430 * sy, sx, sy);
GLfloat red[4] = {1, 0, 0, 1};
glUniform4fv(uniform_color, 1, red);
render_text("The Solid Red Fox Jumps Over The Lazy Dog",
-1 + 8 * sx, 1 - 330 * sy, sx, sy);
render_text("The Solid Red Fox Jumps Over The Lazy Dog",
-1 + 28 * sx, 1 - 450 * sy, sx, sy);
GLfloat transparent_green[4] = {0, 1, 0, 0.5};
glUniform4fv(uniform_color, 1, transparent_green);
render_text("The Transparent Green Fox Jumps Over The Lazy Dog",
-1 + 8 * sx, 1 - 380 * sy, sx, sy);
render_text("The Transparent Green Fox Jumps Over The Lazy Dog",
-1 + 18 * sx, 1 - 440 * sy, sx, sy);
練習
- 嘗試更改背景顏色。
- 嘗試使用 GL_LUMINANCE 和 GL_INTENSITY 而不是 GL_ALPHA。
- 嘗試將混合更改為
glBlendFunc(GL_SRC_ALPHA, GL_ZERO)。 - 嘗試刪除對
glPixelStorei()的呼叫。 - 嘗試不同的紋理包裝和插值模式。
- 嘗試繪製文字
"First line\nSecond line"。發生了什麼? - 繪製每個字元的基線和游標。
- 新增變換矩陣並使用它將文字旋轉 30 度。
- 使用透視變換矩陣,並從傾斜角度檢視文字。