从0开始的OpenGL学习(三十七)-Text Rendering

星球大战片头文字

从0开始的OpenGL学习系列目录

想要在3D世界中绘制文字并不是那么简单的一件事,对类似OpenGL这样低层的API来说更是如此。因为在3D世界中,我们看到的所有物体都应该是3D的,文字也是如此。这就需要3D建模师创建很多很多的文字,把可能用到的文字都创建出来,如果对文字风格有啥需求,还要创建很多套不同风格的文字,这工作量就海了去了。

还有一种更简单,更符合我们认知的一种方式,就是将文字写在什么东西上面。把文字当成图片,“贴”到物体表面上,或者就用更加原始的方法,在表面上绘制点和线,将这些点和线组成文字的样子(这方法太2了,正常人都不会用这方法)。本文中,我们采用将文字当成图片“贴”到物体表面的方式,因为这种方法简单、实现效果好。

看着文章开始的那张图,我们的目标就是要实现这种效果。现在,我们出发!

FreeType库

FreeType库是一个跨平台,支持TrueType字体的三方库。通过它,我们可以加载字体,将字形渲染到位图中,进行一些字体的相关操作。

TrueType是一种由苹果和微软公司联合提出的采用新型数学字形描述技术的计算机字体。它用数学函数描述字体轮廓外形,含有字形构造、颜色填充、数字描述函数、流程条件控制、栅格处理控制、附加提示控制等指令。最重要的是,TrueType类型的字体可以任意放大而不失真(和矢量图片一样)。

你可以到FreeType的官方网站去下载相关资源。不管是下载源码自己编译,还是下载编译好的库,只要确保头文件和库文件放到我们可以引用到的地方就好(例如我们的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;  

字体加载完成后,需要设置face的尺寸,以像素为单位:

FT_Set_Pixel_Sizes(face, 0, 48);  

这一步操作会设置字体的像素宽高。这样的一个face中就包含了很多的字形,我们需要从中提取相应的字形,提取的方法是使用FT_Load_Char函数。比如,我们可以用下面的代码提取字形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字段获取。

绘制的时候,上面的那些属性值都需要用到。这时,我们就有两个选择:第一、使用原生的字形结构,省去我们管理的负担。缺点是调用起来太麻烦,想想每次都用face->glyph->bitmap.width这种格式去访问一个字段就头疼。第二、定义一个我们自己的结构,用来保存每个字形的属性值,保存到一本map中。这样的好处是调用起来方便,缺点是还需要维护一个字形的结构。我们选择第二种方法,因为这样代码更优雅,结构更好。下面是我们自定义的一个字形结构:

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

在这个字形结构中,我们还包含了一个纹理ID,对应了我们加载字形后,用字形信息生成的一张纹理图,一个字形就是一张纹理图。

接着,我们采用最简单的方式创建字形图:每一个字形都创建一张纹理图。一共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);

        //设置纹理选项
        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不要使用字节对齐的限制。OpenGL要求所有的纹理必须是4字节对齐的,也就是说纹理的尺寸必须是4的倍数。一般情况下这并不会造成什么问题因为纹理的宽度会被设置成4的倍数或者每个像素的大小都是4字节的。不过这里我们每个像素只占1个字节(glTexImage2D中的GL_RED属性),开启对齐会造成不必要的麻烦,所以我们将其关掉。

第二个可能产生费解的地方是为什么我们设置纹理格式的时候使用GL_RED而不是GL_RGB?这是因为从字型产生的位图是8位的灰阶图。基于这个原因,我们也不必浪费资源,直接指定内部格式是GL_RED更加省事。使用的时候,我们也只需要采样对应纹素的r分量即可。

最后,用完就清理是一个优秀程序员必备的素养:

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变量就可以表示文字坐标和纹理坐标两个不同的值。作为一个文字显示的示例,我们也不用像在3D场景中绘图那样使用模型、观察、透视投影矩阵来进行计算,直接采用一个正交矩阵就可以。片元着色器中要注意的是我们的文字图只是一个灰度图,需要将这个灰度值作为颜色的Alpha分量输出,这样,经过融合,真正的字就会显示出来。

所以,开启融合是非常必要的一个操作:

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:

// 创建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需要频繁的更新,所以我们要抛弃一直以来的初始化参数:GL_STATIC_DRAW,换上适合频繁更新的参数:GL_DYNAMIC_DRAW。

文字绘制函数

要渲染一个字符,我们需要提取相关的Character结构,计算绘制字符的四边形位置,将四边形位置刷新到VBO中(使用glBufferSubData函数),然后将图片画到四边形中。把这四个步骤封装到一个函数中,就成了如下的代码:

//渲染文本
void RenderText(Shader& s, std::string text, GLfloat x, GLfloat y, GLfloat scale, glm::vec3 color) {
    //激活相关的渲染状态
    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的内容
        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));

绘制结果是:


绘制结果

如果实现有困难,请下载这里的源码进行参考。

实现星战文字效果要解决的问题

研究一下星战文字效果,首先它不是正对这我们,而是有一个倾斜角度,这一点我们很容易就能看出来。其次,文字不停地朝远处移动,我们看到的文字越来越小直至消失。

那么,如何实现这种效果呢?首先,由于要移动,而且远处的文字要变小,我们就不能再用正交投影的方式来计算文字的显示位置,还是要回归透视投影的方式。然后,文字有一定的倾斜角度,我们假设是倾斜45度,向屏幕内倾斜,根据右手盯着,这个倾斜角就是-45度。最后,文字需要远离我们朝屏幕内飞去,我们就需要根据时间不断地调整z坐标值,让它产生远去的效果。总结起来就是这三个问题:

  • 1、如何使用透视投影显示文字?
  • 2、如何对文字进行旋转?
  • 3、如何移动文字?

第一个问题:如何使用透视投影来显示文字?

根据以前的经验,要实现透视效果,我们需要三个变换矩阵:模型、观察和透视。这很容易,之前的代码到处都是,复制过来就好了。然后,输入的顶点需要是三维坐标,我们需要改变顶点的结构:

//每一个字符的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结构之后,相应的顶点属性也需要修改:

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

完成之后,编译运行,看看输出效果。

好像不对?

嗯?不对啊,怎么啥都没有呢?首先想到是文字的坐标是不是输错了,换一个坐标试试,结果,还是没有东西,这就丈二和尚摸不着头脑了。没法子,只能来调试,结果还真找到了问题,请看:


问题

看到框出来的信息没,我们输入的参数值x是0,而字形的偏移x值居然有4,这完全超过了我们显示的区域,这怎么能行,赶紧缩小,缩小多少合适呢?碰运气,缩小成1/720吧。再次运行,果然可以显示出来了。

第二个问题:如何旋转?

这也难不倒我们,旋转函数是现成的:glm::rotate。绕x轴旋转-45度,代码一下子就出来了:

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

编译运行,非常完美

第三个问题:如何移动?

要移动一个物体,我们首先要确定两个东西:其一、移动的方向。其二、移动的速度。移动方向肯定是往屏幕里面去,但是不能直直地往里面去,我们需要一定的角度,简单点就这样设置:glm::vec3 moveDir = glm::vec3(0.0f, 2.5f, -1.0f);。至于速度,更加不用多费心,直接设置成0.005就OK了。

然后,我们需要有当前程序运行了多长时间的数值,用这个数值,乘上移动方向和移动距离,就可以求出一个平移矩阵,用这个矩阵乘上文字位置就可以实现移动的效果:

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

大功告成,来看看最终的效果:


最终效果

太棒了!!!就是我们想要的效果!!!如果你的效果不对,请参考这里的源码

总结

本文中,我们使用了FreeType库来尝试绘制了文字,还实现了星球大战的文字效果。使用FreeType库绘制文字非常简单,只需要将字形解析成一张张独立的图片,然后绘制到平面上就好了。当然,这种绘制非常的浪费资源,我们也可以将字形纹理合并成一张大图,然后检索这张大图来提取某个字符,还能省去切换纹理图的时间,效率太多了。当然,这只是一个想法,具体的实现还需要读者多多尝试,然后总结。

参考资料

Text-Rendering:非常好的介绍网站,本文的大部分代码来源于此
AlphaTestedMagnification:用来解决旋转后的字会模糊的问题

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,271评论 5 476
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,275评论 2 380
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,151评论 0 336
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,550评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,553评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,559评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,924评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,580评论 0 257
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,826评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,578评论 2 320
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,661评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,363评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,940评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,926评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,156评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,872评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,391评论 2 342

推荐阅读更多精彩内容