阅读这篇文章前,可以先看一下这个文字显示系列的其他文章,了解一些字符编码,字体相关的知识:

 

显示文字

OpenGL提供的是图形API,本身并不提供文字处理方面的接口,如果想要显示文字就需要先将文字转化成图片,然后渲染图片。

Char Map

Char Map是最简单的方式,首先需要准备号一张图片,图片中包含了要显示的文字。显示文字时只需要从图片中截取相应的纹理就可以了。这种方式比较适合文字大小固定,显示字符比较少的情况,例如显示数字,只需要“0123456789”十个字符图片就行,有时还会需要“.,”。而且所有数字的宽度一般都是一样的,这对于截取纹理来说非常容易。如下就是一张数字图片:

android opengl 上下文 ffmpeg 保存输出 opengl显示文字_字体平滑

android opengl 上下文 ffmpeg 保存输出 opengl显示文字_位图_02

 

有时我们不光需要数字,也会需要其他字符,可以使用Bitmap font软件导出相应的文字图片,软件界面如下图:

android opengl 上下文 ffmpeg 保存输出 opengl显示文字_显示文字_03

 

软件可以导出文字使用的字体,文字的大小,加粗,斜体等等。也可设置导出图片的格式,文字之间的间隔等等,如下两张图片。

android opengl 上下文 ffmpeg 保存输出 opengl显示文字_OpenGL_04

   

android opengl 上下文 ffmpeg 保存输出 opengl显示文字_FreeType_05

在设置导出图片时,有一个导出文字的描述文件“Font descriptor”的选项,这是因为为了提高导出的图片的利用率,文字会紧密排列在图片中,为了知道每个文字在图片中的位置就需要,一个单独的文件来描述。如下,是导出的图片,和相应的描述文件:

android opengl 上下文 ffmpeg 保存输出 opengl显示文字_位图_06

android opengl 上下文 ffmpeg 保存输出 opengl显示文字_位图_07

描述文件是一个“*.fnt”后缀名的文件,可以直接用记事本打开,从这个文件中可以知道文字的行高“lineHeigt”,字符的Unicode编码“id”,字符宽度“xadvance”,以及字符纹理在导出图片中的位置和长宽等等。有了这些信息,加上导出的图片就可以显示文字了。

 

FreeType解析字体

虽然Char Map的方式可以很好的显示文字,但有一定局限,1.显示的文字有限,对于英文来说字符比较少,可以用一张图片把所有字符打包,但是对于中文,要把所有字符全部打包就不是那么容易了,一般情况下只打包用到的字符;2.字体是固定的,文字的大小也是固定的,如果想更换字体则需要重新打包生成图片。Char Map本身只适合一些固定文字的显示,如果文字内容会变化,或者需要改变字体则不适合。

 

显示文字完全可以通过解析字体文件,然后获取字体的图片,完成文字显示。只要我们有字体文件,字体文件中包含了我们要显示的文字(一般中文字体会包含所有要显示的字符),可以通过FreeType解析字体文件,获取相应的字符图片。通过FreeType解析字体,可以随意更换要显示文字的字体,也可以改变文字的大小等等。

 

通过FreeType这个库可以很容易的从字体文件中获取到字符的位图。FreeType是一个C语言的库,支持各个平台,其提供了CMake编译,可以通过CMake软件转换成VS的工程,这里我已经编译好了VS2015中FreeType静态库的DebugRelease版本。

 

FreeType使用也比较简单,这里给一个字符的大致步骤,详细的可以参考官方文档。

  1. 调用FT_Init_FreeType初始化FreeType库
  2. 调用FT_New_Face使用字体文件创建字体
  3. 调用FT_Select_Charmap设置映射字符编码,默认字符编码是Unicode,通常不用设置,因为大部分字体只支持Unicode和一种Apple平台的老编码。
  4. 调用FT_Set_Char_Size设置字符大小,设置字体大小时要设置字号和DPI,字号和平时用Word中字号类似,DPI是指显示设备像素密度,一般电脑显示器DPI在100左右,而手机在300左右,两者共同决定导出位图的大小。
  5. 调用FT_Set_Transform设置字体的缩放旋转和排版位置。如果想自己控制排版位置,一般可以省略这个步骤。
  6. 调用FT_Load_Char获取文字的位图信息,默认获取的是8位色深的位图,即每个字节代表一个像素。
  7. 使用位图绘制就可以显示文字了。

 

字体缓存

通常显示文字的时候都会有好多字符,如果每次显示文字的时候去获取一次位图,然后创建纹理,这样的效率太低。显示文字的时候会有很多重复的字符,同一个字符纹理只需要获取一次就行;另外如果每个字符创建一个纹理,这会导致创建好多纹理,所以需要一张大的纹理把需要显示的文字打包起来。因为所有显示的文字都在一个纹理中,显示文字的时候可以通过一次调用glDrawArrays渲染一个字符串,大大提高渲染效率。

缓存字体时需要注意以下几个问题

  1. 因为像素坐标是整数,OpenGL中纹理坐标是浮点数,为了防止像素挨得太近,导致显示字符的时候出现其他字符的像素,字符之间需要留一定的空隙。
  2. OpenGL在设置纹理数据时默认是4字节对齐,而FreeType获取的是8bit位图,每个像素只有1个字节,所以需要将纹理数据对其设为一个字节,需要调用glPixelStorei(GL_UNPACK_ALIGNMENT, 1)函数。
  3. 更新字体纹理缓存中的字符是可以通过函数glTexSubImage2D只更新一个字符的数据。
  4. OpenGL中纹理坐标是以坐下角为原点,Y轴是从下到上,而FreeType获取的位图图原点是左上角,Y轴是从上到下,与OpenGL相反,所以纹理缓存中的纹理实际如下:

 

文字边框

FreeType获取的位图是一张刚好包只含文字的位图,不包含左右上下的空白信息。如果绘制文字时直接把每一张位图连接在一起,文字则会一个粘一个,不利于阅读,正常显示的文字上下左右都会有一定的间距。

android opengl 上下文 ffmpeg 保存输出 opengl显示文字_FreeType_08

如上图外面的大矩形框是显示中字时需要的位置,内部红色框是FreeType获取的位图。为了正确显示文字,需要六个位置信息,图中的Height、Width、OffsetX、OffsetY已经位图的长宽。

这六个信息可以通过以下方式获得:

  1. Height,当调用完FT_Set_Char_Size后,所有字符的高度都是一样的,在FT_Set_Char_Size设置文字大小后,可以通过fontFace->size->metrics.height/64获得,除以64说因为FreeType获取的字体高度单位的原因。
  2. Width,当调用完FT_Load_Char后,可以通过fontFace->glyph->advance.x/64,也需要除以64。
  3. OffsetX,当调用完FT_Load_Char后,为fontFace->glyph->metrics.horiBearingX/64。
  4. OffsetY,当调用完FT_Load_Char后,为(fontFace->size->metrics.height + fontFace->size->metrics.descender - fontFace->glyph->metrics.horiBearingY)/64。
  5. Bitmap宽,当调用完FT_Load_Char后,为fontFace->glyph->bitmap.width。
  6. Bitmap高,当调用完FT_Load_Char后,为fontFace->glyph->bitmap.rows。

以上数据主要根据FreeType文档中的这张图片获得:

android opengl 上下文 ffmpeg 保存输出 opengl显示文字_字体平滑_09

其他请参考,FreeType文档

 

除了以上信息,对于每个字符,渲染文字时还需要保存字符纹理在字体缓存纹理中的位置,所以总共需要8个位置信息,我使用了以下结构体存储:
 

struct CharGlyphRect
{
	int width, height; //字符的宽高
	int offsetX, offsetY; // 字符纹理在字符矩形中的偏移量, 坐标系为X从左到右,Y从上到下
	int texWidth, texHeight; // 字符纹理的宽高
	int texX, texY; //字符在缓存大纹理中的位置, 坐标系为X从左到右,Y从上到下
	CharGlyphRect() :texWidth(0), texHeight(0), width(0), height(0), offsetX(0), offsetY(0), texX(0), texY(0) {}
};

为了保存所有已缓存的字符,需要使用一个map,map<wchar_t, CharGlyphRect> CharCache。

 

渲染文字

渲染文字时我们需要渲染一个矩形,如下图时渲染的文字与其对应的矩形:

android opengl 上下文 ffmpeg 保存输出 opengl显示文字_字体平滑_10

渲染的矩形并不是紧挨着的,以为我们的文字纹理只是刚好包含的字符的纹理,没有包括空隙,这个空隙需要我们自己控制。为了一次渲染多个文字,而文字渲染的矩形又不是紧挨着,所以不能使用GL_TRIANGLE_STRIP,只能使用GL_TRIANGLES。一个矩形需要两个三角形,所以需要六个点,六个点的坐标设置如下:
 

const auto& glyphRect = CharCache[str[i]];

	int x = currentX + glyphRect.offsetX;
	int y = currentY - glyphRect.offsetY;

	int texX = glyphRect.texX;
	int texY = glyphRect.texY;
		 
	//以下坐标是渲染时的NDC坐标
	//左上角
	verts[i * 6].pos[0] = PerPixelWidth * x;
	verts[i * 6].pos[1] = PerPixelHeight * y;
	verts[i * 6].texPos[0] = texX / static_cast<float>(CharCacheWidth);
	verts[i * 6].texPos[1] = texY/ static_cast<float>(CharCacheHeight);

	//左下角
	verts[i * 6 + 1].pos[0] = PerPixelWidth * x;
	verts[i * 6 + 1].pos[1] = PerPixelHeight * (y - glyphRect.texHeight);
	verts[i * 6 + 1].texPos[0] = texX / static_cast<float>(CharCacheWidth);
	verts[i * 6 + 1].texPos[1] = (texY + glyphRect.texHeight) / static_cast<float>(CharCacheHeight);

	//右上角
	verts[i * 6 + 2].pos[0] = PerPixelWidth * (x + glyphRect.texWidth);
	verts[i * 6 + 2].pos[1] = PerPixelHeight * y;
	verts[i * 6 + 2].texPos[0] = (texX + glyphRect.texWidth) / static_cast<float>(CharCacheWidth);
	verts[i * 6 + 2].texPos[1] = texY / static_cast<float>(CharCacheHeight);

	//右上角
	verts[i * 6 + 3].pos[0] = PerPixelWidth * (x + glyphRect.texWidth);
	verts[i * 6 + 3].pos[1] = PerPixelHeight * y;
	verts[i * 6 + 3].texPos[0] = (texX + glyphRect.texWidth) / static_cast<float>(CharCacheWidth);
	verts[i * 6 + 3].texPos[1] = texY  / static_cast<float>(CharCacheHeight);

	//左下角
	verts[i * 6 + 4].pos[0] = PerPixelWidth * x;
	verts[i * 6 + 4].pos[1] = PerPixelHeight * (y - glyphRect.texHeight);
	verts[i * 6 + 4].texPos[0] = texX / static_cast<float>(CharCacheWidth);
	verts[i * 6 + 4].texPos[1] = (texY + glyphRect.texHeight) / static_cast<float>(CharCacheHeight);

	//右下角
	verts[i * 6 + 5].pos[0] = PerPixelWidth * (x + glyphRect.texWidth);
	verts[i * 6 + 5].pos[1] = PerPixelHeight * (y - glyphRect.texHeight);
	verts[i * 6 + 5].texPos[0] = (texX + glyphRect.texWidth) / static_cast<float>(CharCacheWidth);
	verts[i * 6 + 5].texPos[1] = (texY + glyphRect.texHeight) / static_cast<float>(CharCacheHeight);

	currentX += glyphRect.width;

平滑字体

文字显示中有大量的曲线,如果要显示清晰的文字必须要让曲线看上去非常平滑,不能出现锯齿。

FreeType默认获取的位图是一个8bit的位图,每个像素只有一个字节,这是一种256度灰阶图。正是这个8位的位图可以让显示的字符具有反走样的能力,可以显示平滑清晰的文字。在OpenGL中最常用的一种抗锯齿反走样方式就是利用透明来进行反走样,具体原理参考。因为FreeType提供的是灰阶图,所以可以设置一个文字颜色,然后把这个灰阶设为像素的透明度,这样显示的文字可以非常平滑,没有锯齿。

片元着色器如下:

#version 430

layout(location= 2) uniform sampler2D tex; //缓存字体纹理

layout(location= 3) uniform vec3 color; //文字颜色

 

out vec4 outColor;

in vec2 texCoord;

void main()

{

float a = texture(tex, texCoord).r;

outColor = vec4(color, a);

}

 

在绘制的时候,因为要使用透明,所以需要开启融合,代码如下:

glActiveTexture(GL_TEXTURE0);

glBindTexture(GL_TEXTURE_2D, CharCacheTex);

glUniform1i(2, 0);

glUniform3fv(3, 1, _color); //设置文字颜色

 

glEnable(GL_BLEND);

glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);//设置融合函数

 

glBindVertexArray(_vao);

glDrawArrays(GL_TRIANGLES, 0, _vertCount);

glDisable(GL_BLEND);

 

以下是一个完整的例子,VS2017代码下载地址

android opengl 上下文 ffmpeg 保存输出 opengl显示文字_字体平滑_11