跳到内容

OpenGL 编程/现代 OpenGL 教程文本渲染 02

来自维基教科书,开放世界中的开放书籍
使用纹理图集优化文本渲染

在第一个文本渲染教程中,我们为绘制的每个字符上传了一个新纹理到显卡。这当然非常浪费。如果我们可以将所有可能的字符图像永久存储在显卡上,那会好得多。一种简单的方法是为每个字符准备多个纹理,这样我们只需在绘制四边形时在纹理之间切换即可。但是,这意味着我们一次只能绘制一个四边形,如果我们有大量的文本,那就意味着大量的 OpenGL 调用。一种稍微复杂的方法是使用单个、大型纹理来存储所有字符,并为每个四边形正确设置纹理坐标,以便渲染正确的字符。这也称为纹理图集

创建纹理图集

[编辑 | 编辑源代码]

纹理图集基本上是一个大型纹理,其中包含许多打包在一起的小图像。如果所有子图像都具有相同的大小,则创建紧密打包的图集相当容易。但是,我们已经看到,FreeType 生成的字形图像大小差异很大。即使使用等宽字体!虽然有各种方法可以有效地打包一组任意大小的矩形,但本教程中将使用一种非常简单的方法:我们将所有字符并排放在一行中。

但是,在我们能够创建图集本身之前,我们需要知道所有字形图像的总宽度和最高字形的高度。假设我们已经初始化了 FreeType,加载了字体,并设置了字体大小,这是一个简单的操作

FT_GlyphSlot g = face->glyph;
int w = 0;
int h = 0;

for(int i = 32; i < 128; i++) {
  if(FT_Load_Char(face, i, FT_LOAD_RENDER)) {
    fprintf(stderr, "Loading character %c failed!\n", i);
    continue;
  }

  w += g->bitmap.width;
  h = std::max(h, g->bitmap.rows);
}

/* you might as well save this value as it is needed later on */
int atlas_width = w;

请记住,变量“g”只是一个为了节省一些输入的快捷方式,本着这种精神,我们还使用了 max() 函数,因此你应该在源代码顶部放置 #include <algorithm>。我们跳过前 32 个 ASCII 字符,因为它们只是控制代码。

现在我们知道了图集的宽度和高度,我们可以为它创建一个空纹理

GLuint tex;
glActiveTexture(GL_TEXTURE0);
glGenTextures(1, &tex);
glBindTexture(GL_TEXTURE_2D, tex);
glPixelStorei(GL_UNPACK_ALIGNMENT, 1);

glTexImage2D(GL_TEXTURE_2D, 0, GL_RED, w, h, 0, GL_ALPHA, GL_UNSIGNED_BYTE, 0);

我们使用了一个空指针来告诉 OpenGL 我们将在稍后填充纹理的内容。同样,不要忘记 GL_UNPACK_ALIGNMENT。现在我们准备将字形图像粘贴到纹理图集中。我们将使用非常方便的 glTexSubImage2D() 函数来实现这一点

int x = 0;

for(int i = 32; i < 128; i++) {
  if(FT_Load_Char(face, i, FT_LOAD_RENDER))
    continue;

  glTexSubImage2D(GL_TEXTURE_2D, 0, x, 0, g->bitmap.width, g->bitmap.rows, GL_ALPHA, GL_UNSIGNED_BYTE, g->bitmap.buffer);

  x += g->bitmap.width;
}

就是这样,我们的纹理图集完成了……除了我们应该尝试记住可以在图集中找到单个字符的位置。

缓存字形度量和纹理偏移量

[编辑 | 编辑源代码]

当我们构建顶点缓冲区时,我们需要知道要使用的正确纹理坐标。最重要的是,我们需要知道 x 偏移量,其余信息可以在 FreeType 的 face->glyph 结构体中找到。但是,始终调用 FT_Load_Char 效率也不高,因此最好的方法是将渲染文本所需的所有信息存储在我们自己的缓存中。由于我们只使用 ASCII 字符集,我们可以只创建一个数组来存储所有 ASCII 字符的信息

struct character_info {
  float ax; // advance.x
  float ay; // advance.y
  
  float bw; // bitmap.width;
  float bh; // bitmap.rows;
  
  float bl; // bitmap_left;
  float bt; // bitmap_top;
  
  float tx; // x offset of glyph in texture coordinates
} c[128];

在我们将字形图像上传到纹理的 for 循环中,我们填充了这个数组

  c[*p].ax = g->advance.x >> 6;
  c[*p].ay = g->advance.y >> 6;

  c[*p].bw = g->bitmap.width;
  c[*p].bh = g->bitmap.rows;

  c[*p].bl = g->bitmap_left;
  c[*p].bt = g->bitmap_top;

  c[*p].tx = (float)x / w;

虽然该结构体只包含 float 类型的成员,但你可以根据字形和图集的最大大小,使用 (u)int16_t 甚至 (u)int8_t 来表示它们。由于我们正在处理 ASCII,我们也可以省略 g->advance.y 值的副本,因为它应该始终为 0。但是,如果你想超越 ASCII,最好不要做任何假设。

使用图集渲染文本行

[编辑 | 编辑源代码]

现在我们真正拥有了所有所需的信息,我们可以构建一个顶点缓冲区,其中包含渲染完整文本行所需的所有信息。让我们将上一教程中的 render_text() 函数调整为使用纹理图集

void render_text(const char *text, float x, float y, float sx, float sy) {
  struct point {
    GLfloat x;
    GLfloat y;
    GLfloat s;
    GLfloat t;
  } coords[6 * strlen(text)];

  int n = 0;

  for(const char *p = text; *p; p++) { 
    float x2 =  x + c[*p].bl * sx;
    float y2 = -y - c[*p].bt * sy;
    float w = c[*p].bw * sx;
    float h = c[*p].bh * sy;

    /* Advance the cursor to the start of the next character */
    x += c[*p].ax * sx;
    y += c[*p].ay * sy;

    /* Skip glyphs that have no pixels */
    if(!w || !h)
      continue;

    coords[n++] = (point){x2,     -y2    , c[*p].tx,                                            0};
    coords[n++] = (point){x2 + w, -y2    , c[*p].tx + c[*p].bw / atlas_width,   0};
    coords[n++] = (point){x2,     -y2 - h, c[*p].tx,                                          c[*p].bh / atlas_height}; //remember: each glyph occupies a different amount of vertical space
    coords[n++] = (point){x2 + w, -y2    , c[*p].tx + c[*p].bw / atlas_width,   0};
    coords[n++] = (point){x2,     -y2 - h, c[*p].tx,                                          c[*p].bh / atlas_height};
    coords[n++] = (point){x2 + w, -y2 - h, c[*p].tx + c[*p].bw / atlas_width,                 c[*p].bh / atlas_height};
  }

  glBufferData(GL_ARRAY_BUFFER, sizeof coords, coords, GL_DYNAMIC_DRAW);
  glDrawArrays(GL_TRIANGLES, 0, n);
}

首先,我们定义了一个结构体来保存每个点的顶点和纹理坐标,以便更容易编写代码来填充所有值。我们需要为将要渲染的每个字符准备 6 个点,因为我们必须使用 GL_TRIANGLES 而不是 GL_TRIANGLE_STRIP 来能够渲染单独的四边形。在循环中,我们执行与之前相同的计算,但使用缓存的字形度量。我们添加了一个优化来跳过绘制宽度和/或高度为零的字形,例如空格字符。循环填充了我们的顶点缓冲区后,我们只需要将其上传到显卡,并告诉它渲染所有三角形即可。

  • 创建一个 struct atlas,它保存特定字体和字体大小的纹理和缓存,这样你就不会再有任何全局变量。使你可以将此类结构体的指针传递给 render_text()
  • 如果你绘制未对齐的文本,你应该会看到比上一个教程更多的渲染伪像。你能解释一下为什么会发生这种情况吗?你将如何防止这种情况发生?
  • 使用索引缓冲区对象,这样你只需要在顶点缓冲区中为每个字符准备 4 个点。这是一个值得的优化吗?
  • 由于我们正在处理像素对齐的坐标,我们实际上不需要浮点坐标。OpenGL 还支持 16 位整数坐标 (GL_SHORT)。将字形度量缓存和顶点缓冲区转换为使用它,并根据需要更改着色器。
  • 如果你有一个带有 GL_SHORT 坐标的 VBO,使用 IBO 仍然值得吗?
  • 纹理图集技术也有助于轻松缓存 VBO(和 IBO),这样你就可以在文本没有改变的情况下无需重新计算任何内容。文本只有最后部分发生变化也很常见。你将如何优化它?

故障排除

[编辑 | 编辑源代码]

如果你在正确渲染时遇到问题,请查看以下列表。

  • 在较新的 OpenGL 版本中,GL_ALPHA 值已不建议在 glTexImage2D 函数中使用。将其替换为 GL_RED 并修改着色器以读取红色通道来修复此问题。有关有效值的列表,请参阅glTexImage2D 文档


< OpenGL 编程

浏览和下载完整代码
华夏公益教科书