学习OpenGL。 课7.2-绘图文字

图片 在图形冒险的某个时刻,您将需要通过OpenGL输出文本。 与您期望的相反,使用诸如OpenGL之类的低级库很难在屏幕上显示一条简单的线。 如果您不需要超过128个不同的字符来绘制文本,那么将不会很困难。 当字符的高度,宽度和偏移量不匹配时,会出现困难。 根据您的住所,您可能需要超过128个字符。 但是,如果您想要特殊字符,数学或音乐字符怎么办? 一旦您知道绘制文本不是最简单的任务,您就会意识到它很可能不应该属于像OpenGL这样的低级API。


由于OpenGL不提供任何呈现文本的方法,因此这种情况下的所有困难都在我们身上。 由于没有图形原语“ Symbol”,我们将不得不自己发明它。 已经有现成的示例:通过GL_LINES绘制符号,创建符号的3D模型或在三维空间中的平面四边形上绘制符号。


大多数情况下,开发人员太懒于 喝咖啡并选择最后一个选项。 绘制这些纹理四边形并不像选择正确的纹理那样困难。 在本教程中,我们将学习一些方法,并使用FreeType编写高级但灵活的文本渲染器。



经典:光栅字体


从前, 在恐龙时代,文本渲染包括选择一种字体(或创建一种字体)以进行应用,并将所需的字符复制到称为位图字体的大型纹理上。 此纹理在某些部分包含所有必需的字符。 这些字符称为字形。 每个字形都具有与之关联的纹理坐标的特定区域。 每次绘制字符时,都选择一个特定的字形,并在平面四边形上仅绘制所需的部分。



在这里,您可以看到我们如何渲染文本“ OpenGL”。 我们采用光栅字体,并从纹理中采样必要的字形,并仔细选择纹理坐标,我们将绘制多个四边形。 打开混合并保持背景透明,我们在屏幕上看到一串字符。 此位图字体是使用Codehead位图字体生成器生成的


这种方法有其优点和缺点。 这种方法的实现很简单,因为位图字体已经被光栅化了。 但是,这并不总是很方便。 如果需要其他字体,则需要生成新的位图字体。 此外,增加字符大小将迅速显示像素化边缘。 此外,位图字体通常与一小组字符相关联,因此Unicode字符很可能不会显示。


这项技术不久前才流行(并且仍然保持流行),因为它非常快并且可以在任何平台上使用。 但是到目前为止,还有其他渲染文本的方法。 其中之一是使用FreeType渲染TrueType字体。


现代性:FreeType


FreeType是一个库,它下载字体,将它们呈现为位图,并为某些与字体相关的操作提供支持。 这个流行的库可用于Mac OS X,Java,Qt,PlayStation,Linux和Android。 加载TrueType字体的能力使该库具有足够的吸引力。


TrueType字体是字形的集合,它不是由像素定义的,而是由数学公式定义的。 与矢量图像一样,可以根据首选字体大小生成光栅化的字体图像。 使用TrueType字体,您可以轻松呈现各种大小的字形而不会降低质量。


FreeType可以从官方网站下载。 您可以自己编译FreeType,也可以在网站上使用预编译的版本(如果有)。 请记住将程序链接到freetype.lib ,并确保编译器知道在何处查找头文件。


然后附加正确的头文件:


 #include <ft2build.h> #include FT_FREETYPE_H 

由于FreeType的设计方式有些奇怪(在编写原始文件时,请告知我是否有所更改),因此只能将其头文件放在带有头文件的文件夹的根目录中。 以其他方式(例如, #include <3rdParty/FreeType/ft2build.h> )连接FreeType可能会引发头文件冲突。

FreeType做什么? 加载TrueType字体并为每个字形生成位图图像,并计算一些字形度量。 我们可以获得位图图像,用于生成纹理并根据接收到的度量来定位每个字形。


要下载字体,我们需要初始化FreeType并将字体加载为面部(因为FreeType调用字体)。 在此示例中,我们加载从C:/ Windows / Fonts文件夹复制的TrueType字体arial.ttf


 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, "fonts/arial.ttf", 0, &face)) std::cout << "ERROR::FREETYPE: Failed to load font" << std::endl; 

这些FreeType函数中的每一个都会在失败的情况下返回非零值。


加载了face字体后 ,我们需要指定所需的字体大小,然后将其提取:


 FT_Set_Pixel_Sizes(face, 0, 48); 

此功能设置字形的宽度和高度。 通过将宽度设置为0(零),我们允许FreeType根据设置的高度计算宽度。


Face FreeType包含字形的集合。 我们可以通过调用FT_Load_Char激活一些字形。 在这里,我们尝试加载字形X


 if (FT_Load_Char(face, 'X', FT_LOAD_RENDER)) std::cout << "ERROR::FREETYTPE: Failed to load Glyph" << std::endl; 

通过将FT_LOAD_RENDER设置为下载标志之一,我们告诉FreeType创建一个8位灰度位图,然后我们可以像这样获得:


 face->glyph->bitmap; 

带有FreeType的字形的大小与位图字体的大小不同。 用FreeType生成的位图是给定字体大小的最小大小,仅足以容纳一个字符。 例如,字形的位图图像. 比字形X的位图小得多X 因此,FreeType也下载一些度量标准,以显示单个字符的大小和位置。 下图显示了FreeType为每个字形计算的指标。



每个字形都位于基线(带箭头的水平线)上。 有些恰好位于基线( X )上,有些低于( gp )。 这些度量标准准确地确定了偏移量,以便将字形准确地定位在基线上,调整字形的大小并找出要留下多少像素才能绘制下一个字形。 以下是我们将使用的指标列表:


  • width :字形宽度(以像素为单位),可通过face->glyph->bitmap.width
  • height :字形高度(以像素为单位),可通过face->glyph->bitmap.rows
  • bearingX :字形左上点相对于原点的水平偏移,通过face->glyph->bitmap_left
  • bearingY :字形左上点相对于原点的垂直偏移,通过face->glyph->bitmap_top
  • advance :相对于原点,以1/64像素为单位的下一个字形开头的水平偏移,可通过face->glyph->advance.x

每次要在屏幕上绘制符号时,我们都可以加载符号的字形,获取其度量并生成纹理,但是在每个帧上为每个符号创建纹理并不是一个好方法。 更好的是,我们将生成的数据保存在某个地方,并在需要时请求它们。 我们定义了一个方便的结构,该结构将存储在std::map


 struct Character { GLuint TextureID; // ID   glm::ivec2 Size; //   glm::ivec2 Bearing; //      GLuint Advance; //       }; std::map<GLchar, Character> Characters; 

在本文中,我们将简化生活,仅使用前128个字符。 对于每个字符,我们将生成一个纹理并将必要的数据保存在Character类型的结构中,该结构将添加到std::map类型的Characters 。 因此,绘制角色所需的所有数据都已保存以备将来使用。


 glPixelStorei(GL_UNPACK_ALIGNMENT, 1); // Disable byte-alignment restriction for (GLubyte c = 0; c < 128; c++) { // Load character glyph if (FT_Load_Char(face, c, FT_LOAD_RENDER)) { std::cout << "ERROR::FREETYTPE: Failed to load Glyph" << std::endl; continue; } // Generate texture 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 ); // Set texture options 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); // Now store character for later use 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)); // Characters[c] = character; } 

在循环内,对于前128个字符中的每个字符,我们得到一个字形,生成纹理,设置其设置并保存度量。 有趣的是,我们使用GL_RED作为internalFormatformat纹理的参数。 字形生成的位图是一个8位灰度图像,其每个像素占用1个字节。 因此,我们将位图缓冲区存储为纹理颜色值。 这是通过创建纹理来实现的,其中每个字节对应于颜色的红色分量。 如果我们使用1个字节来表示纹理颜色,请不要忘记OpenGL的局限性:


 glPixelStorei(GL_UNPACK_ALIGNMENT, 1); 

OpenGL要求所有纹理的偏移量均为4字节,即 它们的大小必须是4个字节的倍数(例如8个字节,4000个字节,2048个字节),或者(并且)每个像素应使用4个字节(例如RGBA格式),但是由于我们每个像素使用1个字节,因此它们可以具有不同的值宽度。 通过将解压缩对齐偏移量设置(是否有更好的转换?),我们消除了可能导致段错误的偏移量错误。


同样,当我们完成字体本身的工作时,我们应该清除FreeType资源:


 FT_Done_Face(face); //     face FT_Done_FreeType(ft); //   FreeType 

着色器


要绘制字形,请使用以下顶点着色器:


 #version 330 core layout (location = 0) in vec4 vertex; // <vec2 pos, vec2 tex_coord> out vec2 TexCoords; uniform mat4 projection; void main() { gl_Position = projection * vec4(vertex.xy, 0.0, 1.0); TexCoords = vertex.zw; } 

我们将符号位置和纹理坐标合并在一个vec4 。 顶点着色器计算坐标与投影矩阵的乘积,并将纹理坐标传输到片段着色器:


 #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; } 

片段着色器接受2个全局变量-字形的单色图像和字形本身的颜色。 首先,我们对字形的颜色值进行采样。 由于纹理数据存储在纹理的红色分量中,因此我们仅将r分量采样为透明度值。 通过更改颜色的透明度,结果颜色将对字形的背景透明,而对字形的真实像素不透明。 我们还将RGB颜色与textColor变量相乘以更改文本的颜色。


但是,要使我们的机制起作用,您需要启用混合:


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

作为投影矩阵,我们将有一个正交投影矩阵。 实际上,要绘制文本,不需要透视矩阵,并且使用正交投影也可以使我们在屏幕坐标中指定所有顶点坐标(如果我们这样设置矩阵):


 glm::mat4 projection = glm::ortho(0.0f, 800.0f, 0.0f, 600.0f); 

我们将矩阵的底部设置为0.0f ,顶部设置为窗口的高度。 结果, y坐标取从屏幕底部( y = 0 )到屏幕顶部( y = 600 )的值。 这表示点(0, 0)和屏幕的左下角。


最后,创建VBO和VAO以绘制四边形。 在这里,我们在VBO中保留了足够的内存,以便我们可以随后更新数据以绘制字符。


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

平坦的四边形需要6个顶点(包含4个浮点数),因此我们保留6 * 4 = 24内存浮点数。 由于我们将经常更改顶点数据,因此我们使用GL_DYNAMIC_DRAW分配内存。


在屏幕上显示一行文本


为了显示一行文本,我们提取与符号相对应的Character结构,并根据符号的度量计算四边形的尺寸。 根据计算出的四边形尺寸,我们可以动态创建一组6个顶点,并使用glBufferSubData更新顶点数据。


为了方便起见, RenderText函数,该函数将绘制一个字符串:


 void RenderText(Shader &s, std::string text, GLfloat x, GLfloat y, GLfloat scale, glm::vec3 color) { // Activate corresponding render state s.Use(); glUniform3f(glGetUniformLocation(s.Program, "textColor"), color.x, color.y, color.z); glActiveTexture(GL_TEXTURE0); glBindVertexArray(VAO); // Iterate through all characters 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; // Update VBO for each character 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 } }; // Render glyph texture over quad glBindTexture(GL_TEXTURE_2D, ch.textureID); // Update content of VBO memory glBindBuffer(GL_ARRAY_BUFFER, VBO); glBufferSubData(GL_ARRAY_BUFFER, 0, sizeof(vertices), vertices); glBindBuffer(GL_ARRAY_BUFFER, 0); // Render quad glDrawArrays(GL_TRIANGLES, 0, 6); // Now advance cursors for next glyph (note that advance is number of 1/64 pixels) x += (ch.Advance >> 6) * scale; // Bitshift by 6 to get value in pixels (2^6 = 64) } glBindVertexArray(0); glBindTexture(GL_TEXTURE_2D, 0); } 

该函数的内容相对清晰:四边形的原点,大小和顶点的计算。 注意,我们将每个指标乘以scale 。 之后,更新VBO并绘制一个四边形。


这行代码需要引起注意:


 GLfloat ypos = y - (ch.Size.y - ch.Bearing.y); 

某些字符(例如pg在基线之下明显绘制,这意味着四边形应显着低于RenderText函数的y参数。 确切的偏移量y_offset可以通过字形指标表示:



要计算偏移量,我们需要直臂来找出符号位于基线下方的距离。 该距离由红色箭头显示。 显然, y_offset = bearingY - heightypos = y + y_offset


如果一切正确,则可以在屏幕上显示如下文本:


 RenderText(shader, "This is sample text", 25.0f, 25.0f, 1.0f, glm::vec3(0.5, 0.8f, 0.2f)); RenderText(shader, "(C) LearnOpenGL.com", 540.0f, 570.0f, 0.5f, glm::vec3(0.3, 0.7f, 0.9f)); 

结果应如下所示:



此处是示例代码(链接到作者的网站)


要了解绘制了哪些四边形,请关闭混合:



从该图可以明显看出,尽管有些字符(例如和和p下移了),但大多数四边形都位于虚构基线的顶部。


接下来呢?


本文介绍了如何使用FreeType呈现TrueType字体。 这种方法在各种字符编码上都是灵活,可伸缩和高效的。 但是,这种方法对于您的应用程序可能太重了,因为会为每个字符创建一个纹理。 首选生产性位图字体,因为我们对所有字形都有一个纹理。 最好的方法是将两种方法结合起来并采取最好的方法:快速从使用FreeType下载的字形中生成光栅字体。 这将使渲染器免于进行大量纹理切换,并且取决于纹理包装,将提高性能。


但是FreeType还有一个缺点:固定大小的字形,这意味着随着渲染字形的大小增加,屏幕上可能会出现台阶,并且旋转时该字形可能看起来模糊。 Valve几年前使用带符号的距离字段解决了此问题(链接到Web存档)。 他们做得很好,并在3D应用程序上进行了展示。


PS :我们有一个电报会议,以协调转账。 如果您有强烈的帮助翻译的愿望,欢迎您!

Source: https://habr.com/ru/post/zh-CN473990/


All Articles