從0開始的OpenGL學(xué)習(xí)(三十七)-Text Rendering

星球大戰(zhàn)片頭文字

從0開始的OpenGL學(xué)習(xí)系列目錄

想要在3D世界中繪制文字并不是那么簡單的一件事,對類似OpenGL這樣低層的API來說更是如此。因為在3D世界中,我們看到的所有物體都應(yīng)該是3D的,文字也是如此。這就需要3D建模師創(chuàng)建很多很多的文字,把可能用到的文字都創(chuàng)建出來,如果對文字風(fēng)格有啥需求,還要創(chuàng)建很多套不同風(fēng)格的文字,這工作量就海了去了。

還有一種更簡單,更符合我們認知的一種方式,就是將文字寫在什么東西上面。把文字當(dāng)成圖片,“貼”到物體表面上,或者就用更加原始的方法,在表面上繪制點和線,將這些點和線組成文字的樣子(這方法太2了,正常人都不會用這方法)。本文中,我們采用將文字當(dāng)成圖片“貼”到物體表面的方式,因為這種方法簡單、實現(xiàn)效果好。

看著文章開始的那張圖,我們的目標(biāo)就是要實現(xiàn)這種效果?,F(xiàn)在,我們出發(fā)!

FreeType庫

FreeType庫是一個跨平臺,支持TrueType字體的三方庫。通過它,我們可以加載字體,將字形渲染到位圖中,進行一些字體的相關(guān)操作。

TrueType是一種由蘋果和微軟公司聯(lián)合提出的采用新型數(shù)學(xué)字形描述技術(shù)的計算機字體。它用數(shù)學(xué)函數(shù)描述字體輪廓外形,含有字形構(gòu)造、顏色填充、數(shù)字描述函數(shù)、流程條件控制、柵格處理控制、附加提示控制等指令。最重要的是,TrueType類型的字體可以任意放大而不失真(和矢量圖片一樣)。

你可以到FreeType的官方網(wǎng)站去下載相關(guān)資源。不管是下載源碼自己編譯,還是下載編譯好的庫,只要確保頭文件和庫文件放到我們可以引用到的地方就好(例如我們的OpenGL目錄下)。

注意:因為代碼本身的原因,如果你想要將頭文件放到OpenGL/include目錄下,請一定要將ft2build.h文件直接放到include目錄下面,否則在引用的時候會出錯,切記,切記!

使用FreeType庫

引用FreeType,首先需要將頭文件包含到目錄中:

#include <ft2build.h>
#include FT_FREETYPE_H  

然后初始化FreeType,加載字體文件(比如arial.ttf)。字體文件加載之后會成為一個被稱作face的東西(FreeType庫的叫法),一個字體文件形成一個face。

FT_Library ft;
if (FT_Init_FreeType(&ft))
    std::cout << "ERROR::FREETYPE: Could not init FreeType Library" << std::endl;

FT_Face face;
if (FT_New_Face(ft, "arial.ttf", 0, &face))
    std::cout << "ERROR::FREETYPE: Failed to load font" << std::endl;  

字體加載完成后,需要設(shè)置face的尺寸,以像素為單位:

FT_Set_Pixel_Sizes(face, 0, 48);  

這一步操作會設(shè)置字體的像素寬高。這樣的一個face中就包含了很多的字形,我們需要從中提取相應(yīng)的字形,提取的方法是使用FT_Load_Char函數(shù)。比如,我們可以用下面的代碼提取字形X:

FT_Load_Char(face, 'X', FT_LOAD_RENDER);

字形的屬性

每個字形都有屬性,這些屬性定義了字形的寬高、偏移等等的信息。下面這幅圖就是字形g的屬性值,我們對照著圖片來仔細觀察字形有些什么屬性。

字形的屬性

圖中的水平軸表示字形的基線,有一些字形位于基線之上,有些字形則會跑到基線下面去(比如g,y,p)。仔細說說上圖中的屬性值:
width:字形的寬度(像素值),通過face->glyph->bitmap.width字段獲取
height:字形的高度(像素值),通過face->glyph->bitmap.rows字段獲取
bearingX:字形位置相對于原點的水平偏移(像素值),通過face->glyph->bitmap_left字段獲取。
bearingY:字形位置相對于原點的垂直偏移(像素值),通過face->glyph->bitmap_top字段獲取。
advance:兩個字形的原點之間的距離值,advance的單位是(1/64像素)。通過face->glyph->advance.x字段獲取。

繪制的時候,上面的那些屬性值都需要用到。這時,我們就有兩個選擇:第一、使用原生的字形結(jié)構(gòu),省去我們管理的負擔(dān)。缺點是調(diào)用起來太麻煩,想想每次都用face->glyph->bitmap.width這種格式去訪問一個字段就頭疼。第二、定義一個我們自己的結(jié)構(gòu),用來保存每個字形的屬性值,保存到一本map中。這樣的好處是調(diào)用起來方便,缺點是還需要維護一個字形的結(jié)構(gòu)。我們選擇第二種方法,因為這樣代碼更優(yōu)雅,結(jié)構(gòu)更好。下面是我們自定義的一個字形結(jié)構(gòu):

struct Character {
    GLuint Texture2D;       //字型紋理的ID
    glm::ivec2 Size;        //字型的尺寸
    glm::ivec2 Bearing;     //字型相對于基線的偏移
    GLuint Advance;         //相對于下一個字型的偏移
};
std::map<GLchar, Character> Characters;

在這個字形結(jié)構(gòu)中,我們還包含了一個紋理ID,對應(yīng)了我們加載字形后,用字形信息生成的一張紋理圖,一個字形就是一張紋理圖。

接著,我們采用最簡單的方式創(chuàng)建字形圖:每一個字形都創(chuàng)建一張紋理圖。一共128章紋理圖,代碼如下:

    glPixelStorei(GL_UNPACK_ALIGNMENT, 1);

    for (GLubyte c = 0; c < 128; ++c) {
        //加載字符字型
        if (FT_Load_Char(face, c, FT_LOAD_RENDER)) {
            std::cout << "ERROR::FREETYPE: 無法加載字型 " << c << std::endl;
            continue;
        }

        GLuint  texture;
        glGenTextures(1, &texture);
        glBindTexture(GL_TEXTURE_2D, texture);
        glTexImage2D(GL_TEXTURE_2D,
            0,
            GL_RED,
            face->glyph->bitmap.width,
            face->glyph->bitmap.rows,
            0,
            GL_RED,
            GL_UNSIGNED_BYTE,
            face->glyph->bitmap.buffer);

        //設(shè)置紋理選項
        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);

        Character character = {
            texture,
            glm::ivec2(face->glyph->bitmap.width, face->glyph->bitmap.rows),
            glm::ivec2(face->glyph->bitmap_left, face->glyph->bitmap_top),
            face->glyph->advance.x
        };
        Characters.insert(std::pair < GLchar, Character>(c, character));
    }
    glBindTexture(GL_TEXTURE_2D, 0);

大部分的代碼都很熟悉,眼睛掃掃過去就知道在干什么了。可能造成困擾的地方有兩處:首先,glPixelStorei(GL_UNPACK_ALIGNMENT, 1);這一行代碼是告訴OpenGL不要使用字節(jié)對齊的限制。OpenGL要求所有的紋理必須是4字節(jié)對齊的,也就是說紋理的尺寸必須是4的倍數(shù)。一般情況下這并不會造成什么問題因為紋理的寬度會被設(shè)置成4的倍數(shù)或者每個像素的大小都是4字節(jié)的。不過這里我們每個像素只占1個字節(jié)(glTexImage2D中的GL_RED屬性),開啟對齊會造成不必要的麻煩,所以我們將其關(guān)掉。

第二個可能產(chǎn)生費解的地方是為什么我們設(shè)置紋理格式的時候使用GL_RED而不是GL_RGB?這是因為從字型產(chǎn)生的位圖是8位的灰階圖?;谶@個原因,我們也不必浪費資源,直接指定內(nèi)部格式是GL_RED更加省事。使用的時候,我們也只需要采樣對應(yīng)紋素的r分量即可。

最后,用完就清理是一個優(yōu)秀程序員必備的素養(yǎng):

FT_Done_Face(face);
FT_Done_FreeType(ft);

著色器

我們用一種非常取巧的方式來寫著色器,來看:

//頂點著色器代碼
#version 330 core
layout (location = 0) in vec4 vertex;   // <vec2 pos, vec2 tex>

out vec2 TexCoords;

uniform mat4 projection;

void main() {
    gl_Position = projection * vec4 (vertex.xy, 0.0, 1.0);
    TexCoords = vertex.zw;
}

//片元著色器代碼
#version 330 core
in vec2 TexCoords;
out vec4 color;

uniform sampler2D text;
uniform vec3 textColor;

void main() {
    vec4 sampled = vec4(1.0, 1.0, 1.0, texture(text, TexCoords).r);
    color = vec4 (textColor, 1.0) * sampled;
}

頂點著色器只需要一個vec4變量就可以表示文字坐標(biāo)和紋理坐標(biāo)兩個不同的值。作為一個文字顯示的示例,我們也不用像在3D場景中繪圖那樣使用模型、觀察、透視投影矩陣來進行計算,直接采用一個正交矩陣就可以。片元著色器中要注意的是我們的文字圖只是一個灰度圖,需要將這個灰度值作為顏色的Alpha分量輸出,這樣,經(jīng)過融合,真正的字就會顯示出來。

所以,開啟融合是非常必要的一個操作:

glEnable(GL_BLEND);
glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA); 

再來定義一個正交投影矩陣:

glm::mat4 projection = glm::ortho(0.0f, 1280.0f, 0.0f, 720.0f);

跟透視投影矩陣相比,正交投影矩陣實在是太容易了,只需要將它指定成窗口的寬高就能非常適配的顯示出來。

最后,別忘了還要定義VAO和VBO:

// 創(chuàng)建VAO和VBO
glGenVertexArrays(1, &VAO);
glGenBuffers(1, &VBO);
glBindVertexArray(VAO);
glBindBuffer(GL_ARRAY_BUFFER, VBO);
glBufferData(GL_ARRAY_BUFFER, sizeof(GLfloat) * 6 * 4, NULL, GL_DYNAMIC_DRAW);
glEnableVertexAttribArray(0);
glVertexAttribPointer(0, 4, GL_FLOAT, GL_FALSE, 4 * sizeof(GLfloat), 0);
glBindBuffer(GL_ARRAY_BUFFER, 0);
glBindVertexArray(0);

VBO需要頻繁的更新,所以我們要拋棄一直以來的初始化參數(shù):GL_STATIC_DRAW,換上適合頻繁更新的參數(shù):GL_DYNAMIC_DRAW。

文字繪制函數(shù)

要渲染一個字符,我們需要提取相關(guān)的Character結(jié)構(gòu),計算繪制字符的四邊形位置,將四邊形位置刷新到VBO中(使用glBufferSubData函數(shù)),然后將圖片畫到四邊形中。把這四個步驟封裝到一個函數(shù)中,就成了如下的代碼:

//渲染文本
void RenderText(Shader& s, std::string text, GLfloat x, GLfloat y, GLfloat scale, glm::vec3 color) {
    //激活相關(guān)的渲染狀態(tài)
    s.use();
    s.setVec3("textColor", color);
    glActiveTexture(GL_TEXTURE0);
    glBindVertexArray(VAO);

    //遍歷每一個字符
    std::string::const_iterator c;
    for (c = text.begin(); c != text.end(); ++c) {
        Character ch = Characters[*c];

        GLfloat xpos = x + ch.Bearing.x * scale;
        GLfloat ypos = y - (ch.Size.y - ch.Bearing.y) * scale;

        GLfloat w = ch.Size.x * scale;
        GLfloat h = ch.Size.y * scale;
        //每一個字符的VBO
        GLfloat vertices[6][4] = {
            { xpos, ypos + h, 0.0, 0.0},
            { xpos, ypos, 0.0, 1.0 },
            { xpos + w, ypos, 1.0, 1.0 },

            { xpos, ypos + h, 0.0, 0.0 },
            { xpos + w, ypos, 1.0, 1.0 },
            {xpos + w, ypos + h, 1.0, 0.0}
        };
        //渲染字型
        glBindTexture(GL_TEXTURE_2D, ch.Texture2D);
        //更新VBO的內(nèi)容
        glBindBuffer(GL_ARRAY_BUFFER, VBO);
        glBufferSubData(GL_ARRAY_BUFFER, 0, sizeof(vertices), vertices);
        glBindBuffer(GL_ARRAY_BUFFER, 0);
        // 渲染四邊形
        glDrawArrays(GL_TRIANGLES, 0, 6);
        //偏移到下一個字符的位置
        x += (ch.Advance >> 6) * scale;
    }

    glBindVertexArray(0);
    glBindTexture(GL_TEXTURE_2D, 0);
}

代碼本身就是最好的解釋??赡軙幸苫蟮膬蓚€地方是:

1、GLfloat ypos = y - (ch.Size.y - ch.Bearing.y) * scale;
ch.Size.y - ch.Bearing.y表示下圖中紅色的部分:


說明

所以,獲得的ypos需要用y減去這一段距離。

2、(ch.Advance >> 6) * scale
這是因為advance的單位是1/64像素,所以需要將這個值往右移6位才是字符的像素偏移值。

完成之后,我們就可以使用,使用的方式如下:

RenderText(shader, "Episode I", 25.0f, 25.0f, 0.5f, glm::vec3(0.5, 0.8f, 0.2f));
RenderText(shader, "THE PHANTOM MENACE", 25.0f, 25.0f + 50 * 1, 0.5f, glm::vec3(0.5, 0.8f, 0.2f));
RenderText(shader, "Turmoil has engulfed the Galactic Republic.The taxation of trade routes to outlaying star systems is in dispute.", 25.0f, 25.0f + 50 * 2, 0.5f, glm::vec3(0.5, 0.8f, 0.2f));
RenderText(shader, "Hoping to resolve the matter with a blockade of deadly battleships, the", 25.0f, 25.0f + 50 * 3, 0.5f, glm::vec3(0.5, 0.8f, 0.2f));
RenderText(shader, "greedy Trade Federation has stopped all shipping to the small planet of", 25.0f, 25.0f + 50 * 4, 0.5f, glm::vec3(0.5, 0.8f, 0.2f));
RenderText(shader, "(C) LearnOpenGL.com", 1020.0f, 690.0f, 0.5f, glm::vec3(0.3f, 0.7f, 0.9f));

繪制結(jié)果是:


繪制結(jié)果

如果實現(xiàn)有困難,請下載這里的源碼進行參考。

實現(xiàn)星戰(zhàn)文字效果要解決的問題

研究一下星戰(zhàn)文字效果,首先它不是正對這我們,而是有一個傾斜角度,這一點我們很容易就能看出來。其次,文字不停地朝遠處移動,我們看到的文字越來越小直至消失。

那么,如何實現(xiàn)這種效果呢?首先,由于要移動,而且遠處的文字要變小,我們就不能再用正交投影的方式來計算文字的顯示位置,還是要回歸透視投影的方式。然后,文字有一定的傾斜角度,我們假設(shè)是傾斜45度,向屏幕內(nèi)傾斜,根據(jù)右手盯著,這個傾斜角就是-45度。最后,文字需要遠離我們朝屏幕內(nèi)飛去,我們就需要根據(jù)時間不斷地調(diào)整z坐標(biāo)值,讓它產(chǎn)生遠去的效果??偨Y(jié)起來就是這三個問題:

  • 1、如何使用透視投影顯示文字?
  • 2、如何對文字進行旋轉(zhuǎn)?
  • 3、如何移動文字?

第一個問題:如何使用透視投影來顯示文字?

根據(jù)以前的經(jīng)驗,要實現(xiàn)透視效果,我們需要三個變換矩陣:模型、觀察和透視。這很容易,之前的代碼到處都是,復(fù)制過來就好了。然后,輸入的頂點需要是三維坐標(biāo),我們需要改變頂點的結(jié)構(gòu):

//每一個字符的VBO
GLfloat vertices[6][5] = {
    { xpos, ypos + h, 0.0,      0.0, 0.0 },
    { xpos, ypos, 0.0,          0.0, 1.0 },
    { xpos + w, ypos, 0.0,      1.0, 1.0 },

    { xpos, ypos + h, 0.0,      0.0, 0.0 },
    { xpos + w, ypos, 0.0,      1.0, 1.0 },
    {xpos + w, ypos + h, 0.0,   1.0, 0.0 }
};

注意改完VBO結(jié)構(gòu)之后,相應(yīng)的頂點屬性也需要修改:

glBufferData(GL_ARRAY_BUFFER, sizeof(GLfloat) * 6 * 5, NULL, GL_DYNAMIC_DRAW);
glEnableVertexAttribArray(0);
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 5 * sizeof(GLfloat), 0);
glEnableVertexAttribArray(1);
glVertexAttribPointer(1, 2, GL_FLOAT, GL_FALSE, 5 * sizeof(GLfloat), (void*)(3 * sizeof(GLfloat)));

將傳遞給頂點著色器的變換矩陣都準備好,一并輸入:

glm::mat4 projection = glm::perspective(glm::radians(camera.Zoom), (float)SCR_WIDTH / (float)SCR_HEIGHT, 0.1f, 100.0f);
glm::mat4 view = camera.GetViewMatrix();
glm::mat4 model;
shader.use();
shader.setMat4("projection", glm::value_ptr(projection));
shader.setMat4("view", glm::value_ptr(view));

完成之后,編譯運行,看看輸出效果。

好像不對?

嗯?不對啊,怎么啥都沒有呢?首先想到是文字的坐標(biāo)是不是輸錯了,換一個坐標(biāo)試試,結(jié)果,還是沒有東西,這就丈二和尚摸不著頭腦了。沒法子,只能來調(diào)試,結(jié)果還真找到了問題,請看:


問題

看到框出來的信息沒,我們輸入的參數(shù)值x是0,而字形的偏移x值居然有4,這完全超過了我們顯示的區(qū)域,這怎么能行,趕緊縮小,縮小多少合適呢?碰運氣,縮小成1/720吧。再次運行,果然可以顯示出來了。

第二個問題:如何旋轉(zhuǎn)?

這也難不倒我們,旋轉(zhuǎn)函數(shù)是現(xiàn)成的:glm::rotate。繞x軸旋轉(zhuǎn)-45度,代碼一下子就出來了:

model = glm::rotate(model, glm::radians(-45.0f), glm::vec3(1.0f, 0.0f, 0.0f));

編譯運行,非常完美

第三個問題:如何移動?

要移動一個物體,我們首先要確定兩個東西:其一、移動的方向。其二、移動的速度。移動方向肯定是往屏幕里面去,但是不能直直地往里面去,我們需要一定的角度,簡單點就這樣設(shè)置:glm::vec3 moveDir = glm::vec3(0.0f, 2.5f, -1.0f);。至于速度,更加不用多費心,直接設(shè)置成0.005就OK了。

然后,我們需要有當(dāng)前程序運行了多長時間的數(shù)值,用這個數(shù)值,乘上移動方向和移動距離,就可以求出一個平移矩陣,用這個矩陣乘上文字位置就可以實現(xiàn)移動的效果:

model = glm::translate(model, moveDir * speed * deltaTime + glm::vec3(0.0f, 0.0f, 0.0f));
shader.setMat4("model", glm::value_ptr(model));

大功告成,來看看最終的效果:


最終效果

太棒了!?。【褪俏覀兿胍男Ч。?!如果你的效果不對,請參考這里的源碼

總結(jié)

本文中,我們使用了FreeType庫來嘗試繪制了文字,還實現(xiàn)了星球大戰(zhàn)的文字效果。使用FreeType庫繪制文字非常簡單,只需要將字形解析成一張張獨立的圖片,然后繪制到平面上就好了。當(dāng)然,這種繪制非常的浪費資源,我們也可以將字形紋理合并成一張大圖,然后檢索這張大圖來提取某個字符,還能省去切換紋理圖的時間,效率太多了。當(dāng)然,這只是一個想法,具體的實現(xiàn)還需要讀者多多嘗試,然后總結(jié)。

參考資料

Text-Rendering:非常好的介紹網(wǎng)站,本文的大部分代碼來源于此
AlphaTestedMagnification:用來解決旋轉(zhuǎn)后的字會模糊的問題

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容