阅读这篇文章前,可以先看一下这个文字显示系列的其他文章,了解一些字符编码,字体相关的知识:https://blog.csdn.net/wlk1229/article/category/9008450
显示文字
OpenGL提供的是图形API,本身并不提供文字处理方面的接口,如果想要显示文字就需要先将文字转化成图片,然后渲染图片。
Char Map
Char Map是最简单的方式,首先需要准备号一张图片,图片中包含了要显示的文字。显示文字时只需要从图片中截取相应的纹理就可以了。这种方式比较适合文字大小固定,显示字符比较少的情况,例如显示数字,只需要“0123456789”十个字符图片就行,有时还会需要“.,”。而且所有数字的宽度一般都是一样的,这对于截取纹理来说非常容易。如下就是一张数字图片:
有时我们不光需要数字,也会需要其他字符,可以使用Bitmap font软件导出相应的文字图片,软件界面如下图:
软件可以导出文字使用的字体,文字的大小,加粗,斜体等等。也可设置导出图片的格式,文字之间的间隔等等,如下两张图片。
在设置导出图片时,有一个导出文字的描述文件“Font descriptor”的选项,这是因为为了提高导出的图片的利用率,文字会紧密排列在图片中,为了知道每个文字在图片中的位置就需要,一个单独的文件来描述。如下,是导出的图片,和相应的描述文件:
描述文件是一个“*.fnt”后缀名的文件,可以直接用记事本打开,从这个文件中可以知道文字的行高“lineHeigt”,字符的Unicode编码“id”,字符宽度“xadvance”,以及字符纹理在导出图片中的位置和长宽等等。有了这些信息,加上导出的图片就可以显示文字了。
FreeType解析字体
虽然Char Map的方式可以很好的显示文字,但有一定局限,1.显示的文字有限,对于英文来说字符比较少,可以用一张图片把所有字符打包,但是对于中文,要把所有字符全部打包就不是那么容易了,一般情况下只打包用到的字符;2.字体是固定的,文字的大小也是固定的,如果想更换字体则需要重新打包生成图片。Char Map本身只适合一些固定文字的显示,如果文字内容会变化,或者需要改变字体则不适合。
显示文字完全可以通过解析字体文件,然后获取字体的图片,完成文字显示。只要我们有字体文件,字体文件中包含了我们要显示的文字(一般中文字体会包含所有要显示的字符),可以通过FreeType解析字体文件,获取相应的字符图片。通过FreeType解析字体,可以随意更换要显示文字的字体,也可以改变文字的大小等等。
通过FreeType这个库可以很容易的从字体文件中获取到字符的位图。FreeType是一个C语言的库,支持各个平台,其提供了CMake编译,可以通过CMake软件转换成VS的工程,这里我已经编译好了VS2015中FreeType静态库的Debug和Release版本。
FreeType使用也比较简单,这里给一个字符的大致步骤,详细的可以参考官方文档。
- 调用FT_Init_FreeType初始化FreeType库
- 调用FT_New_Face使用字体文件创建字体
- 调用FT_Select_Charmap设置映射字符编码,默认字符编码是Unicode,通常不用设置,因为大部分字体只支持Unicode和一种Apple平台的老编码。
- 调用FT_Set_Char_Size设置字符大小,设置字体大小时要设置字号和DPI,字号和平时用Word中字号类似,DPI是指显示设备像素密度,一般电脑显示器DPI在100左右,而手机在300左右,两者共同决定导出位图的大小。
- 调用FT_Set_Transform设置字体的缩放旋转和排版位置。如果想自己控制排版位置,一般可以省略这个步骤。
- 调用FT_Load_Char获取文字的位图信息,默认获取的是8位色深的位图,即每个字节代表一个像素。
- 使用位图绘制就可以显示文字了。
字体缓存
通常显示文字的时候都会有好多字符,如果每次显示文字的时候去获取一次位图,然后创建纹理,这样的效率太低。显示文字的时候会有很多重复的字符,同一个字符纹理只需要获取一次就行;另外如果每个字符创建一个纹理,这会导致创建好多纹理,所以需要一张大的纹理把需要显示的文字打包起来。因为所有显示的文字都在一个纹理中,显示文字的时候可以通过一次调用glDrawArrays渲染一个字符串,大大提高渲染效率。
缓存字体时需要注意以下几个问题
- 因为像素坐标是整数,OpenGL中纹理坐标是浮点数,为了防止像素挨得太近,导致显示字符的时候出现其他字符的像素,字符之间需要留一定的空隙。
- OpenGL在设置纹理数据时默认是4字节对齐,而FreeType获取的是8bit位图,每个像素只有1个字节,所以需要将纹理数据对其设为一个字节,需要调用glPixelStorei(GL_UNPACK_ALIGNMENT, 1)函数。
- 更新字体纹理缓存中的字符是可以通过函数glTexSubImage2D只更新一个字符的数据。
- OpenGL中纹理坐标是以坐下角为原点,Y轴是从下到上,而FreeType获取的位图图原点是左上角,Y轴是从上到下,与OpenGL相反,所以纹理缓存中的纹理实际如下:
文字边框
FreeType获取的位图是一张刚好包只含文字的位图,不包含左右上下的空白信息。如果绘制文字时直接把每一张位图连接在一起,文字则会一个粘一个,不利于阅读,正常显示的文字上下左右都会有一定的间距。
如上图外面的大矩形框是显示中字时需要的位置,内部红色框是FreeType获取的位图。为了正确显示文字,需要六个位置信息,图中的Height、Width、OffsetX、OffsetY已经位图的长宽。
这六个信息可以通过以下方式获得:
- Height,当调用完FT_Set_Char_Size后,所有字符的高度都是一样的,在FT_Set_Char_Size设置文字大小后,可以通过fontFace->size->metrics.height/64获得,除以64说因为FreeType获取的字体高度单位的原因。
- Width,当调用完FT_Load_Char后,可以通过fontFace->glyph->advance.x/64,也需要除以64。
- OffsetX,当调用完FT_Load_Char后,为fontFace->glyph->metrics.horiBearingX/64。
- OffsetY,当调用完FT_Load_Char后,为(fontFace->size->metrics.height + fontFace->size->metrics.descender - fontFace->glyph->metrics.horiBearingY)/64。
- Bitmap宽,当调用完FT_Load_Char后,为fontFace->glyph->bitmap.width。
- Bitmap高,当调用完FT_Load_Char后,为fontFace->glyph->bitmap.rows。
以上数据主要根据FreeType文档中的这张图片获得:
其他请参考,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。
渲染文字
渲染文字时我们需要渲染一个矩形,如下图时渲染的文字与其对应的矩形:
渲染的矩形并不是紧挨着的,以为我们的文字纹理只是刚好包含的字符的纹理,没有包括空隙,这个空隙需要我们自己控制。为了一次渲染多个文字,而文字渲染的矩形又不是紧挨着,所以不能使用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代码下载地址: