Android OpenGL ES 渲染文本

1 前言

先来个灵魂拷问:为什么要研究OpenGL渲染文本? 用Android的canvas,不是更香吗?!

这就看应用场景了,一个纯粹的UI界面,确实不需要用到OpenGL,但是,复杂一些的,例如弹幕,用OpenGL,效果就会好很多。

那么Canvas和OpenGL有什么区别?
Canvas是2D图形的API,如果不开启硬件加速,则使用CPU绘制(底层通过skia引擎,纯软件),如果开启硬件加速,则使用GPU绘制(内部通过OpenGLRender把Canvas的工作交给GPU,硬件绘制)。

OpenGL是3D图形的API,默认走GPU绘制,即硬件实现。

所以区别就出来了:手机型号或android版本都可能影响是否支持硬件加速,所以Canvas不能保证都能通过GPU绘制,也就不能保证稳定的帧率,通常可能只能做到30fps。
而OpenGL,直接使用硬件GPU,帧率可以提高到和手机的VSYNC一样,例如60fps!

另外,OpenGL支持3D,可以实现更酷炫的效果。

所以本文讨论的原因有了,真不是瞎折腾,而是事出有因。现在从基础开始,我们怎么把文字给渲染出来。

我们先无脑想一下,渲染文字,需要做什么?

OpenGL基本都离不开纹理,所以其实可以把文字,做成纹理,然后像贴图片一样,贴到屏幕上,不就可以了

例如,我们是一个英文环境,那非常简单,所用的字符,不过就是ASCII码,总共只有256个。
在这里插入图片描述
把上面的图,作为一张纹理,绘制哪个文字,就从纹理的哪个坐标截取。通过启用混合,让背景保持透明,最终就能渲染一个字符串到屏幕上。

思路这样没错,但是会有一些问题。
(1) 文字分辨率如何保持?即,一张图如何适配到不同的分辨率屏幕上?
(2)文字颜色如何修改?
(3) 文字如果更多,例如汉字,怎么办?

我们来思考一下每个问题的思路。
(1) 文字分辨率如何保持?
不用上面的一张图表示所有文字,而是每个文字都有一个图。然后,根据代码设置的字号大小,再动态生成每个文字对应分辨率的纹理。有什么第三方库能做到吗?有,那就是FreeType库。后文将会重点展开。

(2)文字颜色如何修改?
加载文字纹理,只提取灰度值(即显示或不显示),颜色在渲染时动态配置。
直接上片段着色器的代码说明。

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

如上,text是文字的纹理。
sampled只提取了纹理的一个灰度值。(用r存灰度值)
textColor是代码配置的文字颜色,color是着色器的输出,即文字的最终效果,颜色来自textColor,透明度来自纹理。

(3) 文字如果更多,例如汉字,怎么办?
把常用的文字提前生成好纹理,绘制时直接使用。 不常用的文字,根据需要动态生成,然后使用。

2 字体文件与解析库FreeType介绍

2.1 字体文件

字体格式类型主要有几个大分类:TrueTypeOpenTypeWOFFSVG。其中最出名的是前两个。下面简单介绍一下。

2.1.1 TrueType

文件后缀是.ttf。微软和苹果联合推出,所以也是Windows和Mac系统最常用的字体格式。
TrueType字体中的字符(或字形)轮廓由直线和二次贝塞尔曲线(bézier)片段构成。所以它和文字大小没关系,放大放小,都可以保证文字的清晰度,锯齿?不存在的。

2.1.2 OpenType

文件后缀是.otf。微软和Adobe联合推出。它也是一种轮廓字体,比TrueType更为强大。特别是体现在跨平台上。

从OpenType文件结构来说,确切地讲它是TrueType 格式的扩展延伸,它在继承了TrueType格式的基础上,增加了对PostScript字型数据(主要应用在打印机)的支持,所以OpenType的字型数据,既可以采用TrueType的字型描述方式,也可以采用PostScript的字型描述方式,这完全由字体厂商来选择决定。从文件结构的角度来讲OpenType或许并不是一种真正新的字体格式,但是该字体格式所增加的排版特性却从功能上为用户开辟了新的用字方式。

2.2 字体解析库FreeType

FreeType是一个开源的字体解析库,非常的通用,windows, ios,android等操作系统,都或多或少用到了这个库。支持各种字体格式,包括上面提的TTF或OTF。想看官网或下载源码,点这里

FreeType加载一个字体库,很简单。

    if (FT_Init_FreeType(&ft)) {
    
    
        LOGCATE("ERROR::FREETYPE: Could not init FreeType Library");
        return false;
    }

    // find path to fonts
    std::string font_name = ASSETS_DIR + "/fonts/Antonio-Bold.ttf";
    // load fonts as face
    FT_Face face;
    if (FT_New_Face(ft, font_name.c_str(), 0, &face)) {
    
    
        LOGCATE("ERROR::FREETYPE: Failed to load fonts");
        return false;
    }

FT_New_Face得到FT_Face。这是一个比较重要的结构体,加载每个文字就通过这个face。

Face加载完成之后,我们需要定义字体大小,这表示着我们要从字体面中生成多大的字形:
例如:

FT_Set_Pixel_Sizes(face, 0, 96);

第二个和第三个参数,代表宽和高。如果将宽度值设为0,表示我们要从字体面通过给定的高度中动态计算出字形的宽度。

3 文字渲染

文字渲染的主要工作:
(1) 用FreeType,提前生成常用文字的bitmap,并作为纹理图片,上传到GPU(1000+个小图片,GPU完全可以承受,不用害怕)
(2) 绘制某个文字时,检查时否已经有纹理数据,没有的话,做(1)的工作。(不再常用文字范围内的文字,就会遇到)
(3) 计算某个文字的顶点坐标
(4) 开始绘制该文字
(5) 重复2~3~4,直到一段文字绘制完成

接下来具体展开讨论。

首先,一个小目标是把文字,生成一个小小的bitmap,作为纹理。当然了,每个文字的宽度是不一样的,这也很好理解,例如一个小点点.和一个字母A,占用的宽度空间,不应该一样。

另外,除了bitmap,freetype还会给出文字的一些数学参数。
来了解一下,一个文字都包含哪些参数:
在这里插入图片描述
上面是文字的一些数学参数。其中水平线Baseline最重要(即上面的水平箭头)。每个文字渲染时,应该基于基准线摆放才好看。

下面是一些参数的详细信息:

属性 获取方式 描述
width face->glyph->bitmap.width 位图宽度(像素)
height face->glyph->bitmap.rows 位图高度(像素)
bearingX face->glyph->bitmap_left 水平距离,即位图相对于原点的水平位置(像素)
bearingY face->glyph->bitmap_top 垂直距离,即位图相对于基准线的垂直位置(像素)
advance face->glyph->advance.x 水平预留值,即原点到下一个字形原点的水平距离(单位:1/64像素)

这些参数,在计算文字的顶点坐标时,将会使用到。

好了,激动人心的时刻来了,看一下怎么样加载字体,这里给出一个完整的加载字体的函数。

int TextSample::makeTextAsGLTexture(const wchar_t *text, int size) {
    
    
    // find path to fonts
    std::string font_name = ASSETS_DIR + "/fonts/chinese_lvshu.ttf";

    // load fonts as face
    FT_Face face;
    if (FT_New_Face(ft, font_name.c_str(), 0, &face)) {
    
    
        LOGCATE("ERROR::FREETYPE: Failed to load fonts");
        return false;
    }

    // Set size to load glyphs as
    FT_Set_Pixel_Sizes(face, 0, 96);
    FT_Select_Charmap(face, ft_encoding_unicode);

    glPixelStorei(GL_UNPACK_ALIGNMENT, 1);

    for (int i = 0; i < size; ++i) {
    
    
        //int index =  FT_Get_Char_Index(face,unicodeArr[i]);
        if (FT_Load_Glyph(face, FT_Get_Char_Index(face, text[i]), FT_LOAD_DEFAULT)) {
    
    
            LOGCATE("Failed to load Glyph");
            continue;
        }

        FT_Glyph glyph;
        FT_Get_Glyph(face->glyph, &glyph);

        //Convert the glyph to a bitmap.
        FT_Glyph_To_Bitmap(&glyph, ft_render_mode_normal, 0, 1);
        FT_BitmapGlyph bitmap_glyph = (FT_BitmapGlyph) glyph;

        //This reference will make accessing the bitmap easier
        FT_Bitmap &bitmap = bitmap_glyph->bitmap;

        // Generate texture
        GLuint texture;
        glGenTextures(1, &texture);
        glBindTexture(GL_TEXTURE_2D, texture);
        glTexImage2D(
                GL_TEXTURE_2D,
                0,
                GL_LUMINANCE,
                bitmap.width,
                bitmap.rows,
                0,
                GL_LUMINANCE,
                GL_UNSIGNED_BYTE,
                bitmap.buffer
        );

        LOGCATE("initFreeType textureId %d, text[i]=%d [w,h,buffer]=[%d, %d, %p], advance.x=%ld",
                texture, text[i], bitmap.width, bitmap.rows, bitmap.buffer,
                glyph->advance.x / MAX_SHORT_VALUE);
        // 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(bitmap.width, bitmap.rows),
                glm::ivec2(face->glyph->bitmap_left, face->glyph->bitmap_top),
                static_cast<GLuint>((glyph->advance.x / MAX_SHORT_VALUE) << 6)
        };
        LOGCATE("initFreeType, add to slot[%d], size (%d,%d), bearing(%d, %d)", text[i],
                bitmap.width, bitmap.rows, face->glyph->bitmap_left, face->glyph->bitmap_top);
        mCharacters.insert(std::pair<GLint, Character>(text[i], character));
    }
    glBindTexture(GL_TEXTURE_2D, 0);
    FT_Done_Face(face);
    return 0;
}

函数参数 wchar_t *text是文字数组。注意,如果是纯英文字体,则不需要用wchar_t,只要char就行了。wchar_t占用两字节,中文字符是Unicode编码,需要两个字节。

再来看一下makeTextAsGLTexture函数都做了什么

(1) FT_Load_Glyph: 加载文字数组的某个文字;
(2) FT_Get_Glyph(face->glyph, &glyph): 从face结构体中,提取glyph
(3) FT_Glyph_To_Bitmap : 生成文字的bitmap
(4) glGenTextures & glTexImage2D: 把bitmap生成纹理,上传到GPU
(5) mCharacters.insert: 把纹理id,文字的数学参数一起存储,方便渲染时查询并使用。

好了,我们来看一下函数怎么调用:

static const wchar_t CHINESE_COMMON[] = L"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghigklmnopqrstuvwxyz12367890的一是了我不人在他有这个上们来到时大地为子中你说生国年着就那和";

makeTextAsGLTexture(CHINESE_COMMON, sizeof(CHINESE_COMMON) / sizeof(CHINESE_COMMON[0]) - 1);

CHINESE_COMMON数组定义了常用的中文文字,大概1000多个,因为篇幅原因,我就没全贴了,可以看文章最后,我的源码。当然了,其实中文字体库,也包括了英文字母和数字,毕竟只有几十个,支持一下很简单,所以把英文字母也加上了。这样就不用即加载中文字体,又加载英文字体了。

makeTextAsGLTexture在初始化时调用一次即可。

接下来,给一个渲染函数RenderTextChinese,每次onDraw时都会调用。

void TextSample::RenderTextChinese(Shader *shader, const wchar_t *text, int textLen, GLfloat x,
                                   GLfloat y, GLfloat scale,
                                   glm::vec3 color, glm::vec2 viewport) {
    
    
    // 激活合适的渲染状态
    shader->setVec3("textColor", color);
    glBindVertexArray(VAO);
    checkGLError("RenderTextChinese");
    x *= viewport.x;
    y *= viewport.y;
    for (int i = 0; i < textLen; ++i) {
    
    
        Character ch;
        getCharacter(text[i], ch);
        LOGCATD("RenderTextChinese, slot[%d], textureId %d", text[i], ch.TextureID);

        GLfloat xpos = x + ch.Bearing.x * scale;
        GLfloat ypos = y - (ch.Size.y - ch.Bearing.y) * scale;

        xpos /= viewport.x;
        ypos /= viewport.y;

        GLfloat w = ch.Size.x * scale;
        GLfloat h = ch.Size.y * scale;

        w /= viewport.x;
        h /= viewport.y;

        LOGCATD("RenderTextChinese [xpos,ypos,w,h]=[%f, %f, %f, %f]", xpos, ypos, w, h);

        // 当前字符的VBO
        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}
        };

        // 在方块上绘制字形纹理
        glActiveTexture(GL_TEXTURE0);
        glBindTexture(GL_TEXTURE_2D, ch.TextureID);
        //glUniform1i(m_SamplerLoc, 0);
        checkGLError("RenderTextChinese 2");
        // 更新当前字符的VBO
        glBindBuffer(GL_ARRAY_BUFFER, VBO);
        glBufferSubData(GL_ARRAY_BUFFER, 0, sizeof(vertices), vertices);
        checkGLError("RenderTextChinese 3");
        glBindBuffer(GL_ARRAY_BUFFER, 0);
        // 绘制方块
        glDrawArrays(GL_TRIANGLES, 0, 6);
        checkGLError("RenderTextChinese 4");
        // 更新位置到下一个字形的原点,注意单位是1/64像素
        x += (ch.Advance >> 6) * scale; //(2^6 = 64)
    }
    glBindVertexArray(0);
    glBindTexture(GL_TEXTURE_2D, 0);
}

这个函数具体工作:
(1) getCharacter(text[i], ch); 根据文字的Unicode的值,读取上面存的Character结构体,如果读取不到,说明不是常用的文字,getCharacter函数内会再次调用makeTextAsGLTexture,生成这个生僻字的对应的Character并返回。
代码不多,直接明示:

void TextSample::getCharacter(const wchar_t oneText, Character &ch) {
    
    
    if (mCharacters.find(oneText) != mCharacters.end()) {
    
    
        ch = mCharacters[oneText];
    } else {
    
    
        LOGCATD("getCharacter, make a new text");
        const wchar_t temp[] = {
    
    oneText};
        makeTextAsGLTexture(temp, 1);
        ch = mCharacters[oneText];
    }
}

(2) GLfloat vertices[6][4] 生成文字对应的顶点坐标,使用的是归一化坐标,即【-1.0f ~ 1.0f】。一个文字四边形,对应2个三角形,所以需要6个顶点。因为是3D场景,所以每个坐标是4个值(x, y, z, w)。
(3) glBindTexture 绑定文字对应的纹理
(4) glBindBuffer 绑定顶点
(5) glDrawArrays 绘制一个文字
(6) for循环遍历步骤1~5,绘制完一个文字数组。

来看一下函数的调用方式:

static const wchar_t CHINESE_TEST[] = L"Love小爱心HAHA";

glm::vec2 viewport(screenW, screenH);

RenderTextChinese(m_pShader, CHINESE_TEST,
                      sizeof(CHINESE_TEST) / sizeof(CHINESE_TEST[0]) - 1, -0.9f, -0.1f, 1.0f,
                      glm::vec3(0.5f, 0.8f, 0.2f), viewport);

CHINESE_TEST是输入的文字数组。

为什么第三个参数,sizeof(CHINESE_TEST) / sizeof(CHINESE_TEST[0]) - 1 要减去1?因为还有一个换行符号\,这个不需要渲染。

至此,关键的工作已经说明完成,但相信有很多细节你还想了解,请直接看我的源码哈:
OpenGLESDemo

效果图

在这里插入图片描述

参考

Learn OpenGL Text-Rendering

猜你喜欢

转载自blog.csdn.net/newchenxf/article/details/122538671