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 度。
- 使用透视变换矩阵,并从倾斜角度查看文本。