当前位置: 首页 > 科技观察

Android字体渲染器:使用OpenGLES进行高效的文本渲染

时间:2023-03-16 20:37:55 科技观察

任何具有多年客户端开发经验的开发人员都应该知道文本渲染是多么复杂。至少在2010年之前,当我第一次开始编写libhwui(这是一个基于Android2.0的2D绘图库)时,我意识到处理文本有时比其他的更复杂,尤其是当您尝试在屏幕上绘图时使用GPU时.文本和AndroidAndroid上的文本渲染加速器硬件最初是由Renderscript团队编写的,然后经过包括我和我的朋友ChetHaase在内的许多工程师的改进和优化。在网上很容易找到很多关于如何使用OpenGLES渲染文本的教程。如果你觉得还不够,可以看看游戏的文章,只看文字渲染的部分。这篇文章不是很新奇的知识,但是对于很多开发者来说,这篇文章可以让你深入了解如何实现一个基于GPU的文本渲染系统。文章还介绍了一些比较容易实现的优化方法。使用OpenGL渲染文本的常用方法是计算包含所需字形的所有纹理集。这个操作通常使用一些相当复杂的算法离线执行,在构造字形时可以更高效。在创建这样的纹理集之前,您首先需要知道应用程序在运行时将使用的字体,包括字体样式、大小和其他属性。在Android上,提前生成字体纹理不是一个实用的解决方案。Android上的UI工具无法知道应用系统将使用什么字体和字形,应用程序还可以在运行时加载自定义字体,这是主要限制。Android字体渲染也必须遵循以下规则:必须在运行时构建字体缓存;它必须能够处理大量的字体;它必须能够处理大量的符号;它必须尽量减少字体的资源消耗;它必须跑得快;适用于低端和高端机器;可以安全地与其他组件(驱动程序或GPU)结合使用。字体渲染器的实现在了解底层OpenGL字体渲染器的工作原理之前,让我们从应用层使用的高级API开始。这些API对于理解libhwui非常重要。文本API布局和绘制文本的主要API有四种:android.widget.TextView:一个可以处理文本布局和渲染的视图组件。android.text.*:创建程式化文本和布局的类的集合。android.graphics.Paint:用于测量文本。android.graphics.Canvas:用于渲染文字。TextView和android.text都是Paint和Canvas上的高级API。Android3.0之后,Paint和Canvas直接在开源渲染库Skia上实现。SKia提供了Freetype的一个很好的抽象,Freetype是一种流行的开源字体光栅化器。对于Android4.4,情况就变得有点复杂了。Paint和Canvas都使用称为TextLayoutCache的内部JNIAPI。它可以处理复杂的文本布局(CTL)。这个API依赖于Harfbuzz,一个空间开源字形引擎。TextLayoutCache的输入是字体和JavaUTF-16字符串,输出是带有x/y坐标的字形列表。TextLayoutCache主要是支持非拉丁语言,如阿拉伯语、希伯来语、泰语等。本文不会讲解TextLayoutCache和Harfbuzz的工作原理,但强烈推荐读者学习CTL。如果您在开发应用程序时需要支持非拉丁语言环境,则必须学习它。如果您曾经参与过OpenGL文本渲染文章中的讨论,您会发现这种特殊问题很少见。绘图类型比简单地排列字形更复杂。有些语言,例如阿拉伯语是从右到左的,泰语甚至要求字形排列在前一个字形的上方或下方。也就是说,当直接或间接调用Canvas.drawText()函数时,OpenGL渲染器接收的不是你发送的参数,而是一串数字、符号ID和x/y坐标。光栅化缓存字体渲染器的每一种绘制方法都与一种字体相关。字体用于缓存单个字形,这些字形又存储在缓存结构中(缓存结构可以包含来自不同字体的字形)。缓存结构是一个重要的对象,它持有多个缓冲区,包括块集合、像素缓冲区、OpenGL结构处理器和点阵缓冲区(即网格)。该对象存储的数据结构比较简单:字体存储在字体渲染器的LRU缓存中;字形符号存放在对应的地图字体集合中(key为字形文件的标识);缓存结构使用一个blocklistSet来记录空间的大小;像素缓冲区是uint8_t或uint32_t类型的数组(用于alpha值和RGBA缓冲区);网格实际上是一个具有两个属性的顶点数组:x/y位置和u/v坐标;一个GLuint处理程序。字体渲染器为不同类型的缓存结构提供了几种缓存纹理实例,根据不同的大小来区分。此大小可能会根据不同的设备而有所不同。这里提到了默认大小(缓存的数量是硬编码的):1024*512alpha缓存。2048*256阿尔法缓存。2028*512透明缓存。1024*512透明缓存。2048*256阿尔法缓存。缓存纹理对象创建后,其对应的buffer不会自动分配空间,除了1024*512的alpha缓存总是自动分配,其他都是根据需要分配空间。字形符号作为列打包到纹理中,每当字体渲染器遇到它不缓存的符号时,它会向缓存纹理询问响应类型(存储在上面的有序列表中),然后缓存该符号。这就是上面的块列表的用武之地。该列表包含当前分配的列和任何未分配的空间。如果字形与现有列匹配,则该字形将添加到列的底部。如果所有列都被占用,则从左侧的剩余空间中打开新列。由于所有字体都是等宽字体,渲染器将使每个字形的宽度成为4像素的倍数(默认为4像素)。这是列重用和字形包装之间的权衡,目前还不是很好,但实现起来更快。所有字形都存储在具有1像素边框的结构中,这避免了双线性过滤采样时的伪影。在使用缩放操作渲染文本时,了解文本何时被渲染也很重要。这种变形操作直接由Skia/Freetype处理,这意味着字形被变形存储在缓存结构中。这提高了渲染质量。幸运的是,文本很少进行缩放和动画处理,即使使用了,也只是设计了很少的字形。做了很多实验,但是一直没有找到实际使用的场景。还有其他影响字形光栅化和存储的绘画相关属性:粗体、斜体和X缩放(画布上的矩阵变换)、字体样式和线宽。光栅化的替代方法事实上,还有其他方法可以在GPU上处理文本字形。矢量可以直接渲染,但这样做很昂贵。我研究了标记距离场的方法,但遇到了简单实现的准确性问题(创建曲线时不稳定)。我建议读者可以看看Glyphy项目。这是一个开源库,由Harfbuzz编写。该项目扩展了标记距离场的技术,同时也解决了精度问题。我暂时没有花太多时间看这个项目。但是上次做shaders的时候发现这个技术在Android上是禁止的。预缓存技术Glyph符号缓存是必须要做的。如果做预缓存,效果会更好。因为libhwui是一个延迟渲染器(与Skia的快速模式相反),所有出现在屏幕上的字形都是逐帧开始的。在一系列显示操作(批处理和合并操作)期间,字体渲染器需要缓存尽可能多的字形符号。使用预缓存技术的主要优点是可以完全或最小化纹理加载时间。纹理加载操作非常昂贵,它会延迟CPU或GPU。即使在帧渲染期间,改变纹理也会给GPU架构带来更多的内存压力。ImaginationTech的PowerVRmlSGXGPU使用延迟叠加架构,提供许多有趣的功能。但是如果在渲染帧时需要修改纹理,则驱动程序将被迫复制纹理。因为字体结构相当大,所以如果处理不好纹理加载很容易耗尽内存。这样的场景在GooglePlay上的一款应用中确实发生过。这个APP是一个简单的计算器,只使用一些数学符号和数字作简单的绘图按钮。在某些时候,字体渲染器甚至无法渲染第一帧。因为按钮是按顺序绘制的,所以每个按钮都会触发纹理加载,然后复制整个字体缓存。系统根本没有那么多内存来存储这么多缓存备份。清空缓存由于用作字形缓存的纹理非常大,它们有时会被系统回收,以便为其他程序提供更多RAM。当用户隐藏当前应用程序时,系统会向应用程序发送一条消息,要求释放尽可能多的内存。显然,这需要破坏最新的字形缓存结构。在Android中,这个大缓存结构就是所有字形的缓存。默认创建的第一个除外(默认缓存1024*512)。当没有存储空间时,纹理结构将被清除。字体渲染器使用LRU算法记录所有的字体,也就是记录而已。如有必要,根据最近最少使用的纹理清除内存。目前没有提供这个操作,但确实是一个很好的优化策略。批处理和合并操作Android4.3引入的绘图批处理和合并操作是一项重要的优化,彻底减少了向OpenGL驱动发送大量指令的问题。对于合并操作,字体渲染器在进行多次绘制调用时缓存文本,每个缓存的纹理将为客户端提供一个包含2048个四边形的数组(1个四边形=1个字形)。在lilbhwui中调用其中一个文本绘制API时,字体渲染器会获取适当的网格来绘制每个字形的位置和u/v坐标。网格在批处理结束时发送到GPU(由延迟显示系统确定)。或者当一个quad的buffer满了的时候,可能会出现多个grid渲染同一个string的情况——一个字符buffer占用一个grid。这种优化过程易于实现,对显示效果有很大帮助。由于字体渲染器使用了多缓冲区结构,在字符串的渲染过程中,字形符号可能来自不同的纹理。如果没有批处理和合并操作,每个绘制调用都必须传递给GPU。字体渲染器需要不断地在不同的缓存结构之间切换,会带来很大的消耗。测试字体渲染器时,我在测试应用程序中看到了这个问题。该应用程序只是以不同的样式和大小呈现“helloworld”。字母“o”存储在与其他字符不同的纹理中。这种情况导致字体渲染器最初只绘制“hell”,然后是“o”,然后是“w”,然后是“o”,然后是“rld”。这5个drawcall和5个texture绑定连接后,其实只需要其中两个,现在渲染器先画“hellwrld”,然后把两个“o”一起画,这就是batch和batch的好处合并操作消失了。优化纹理加载如前所述,字体渲染会在更新缓存纹理时加载尽可能少的数据(在每个纹理中记录脏数据块)。不幸的是,这种方法有两个局限性。首先,OpenGLES2.0不允许任意上传一个矩形区域。glTextSubImage2D将允许您指定矩形的x/y坐标和宽度和高度,以更新矩形内的纹理。并将矩形的宽度作为内存中的数据范围。这可以通过创建一个合适大小的CPU缓冲区来解决,但是它还需要事先知道矩形有多大。有一个很好的权衡,即加载包含脏数据块(矩形)的最小像素带。由于此像素带与纹理一样宽,因此可以节省空间。比每次都更新整个纹理要好得多。第二个问题是纹理加载是一个异步调用,这会导致相当大的CPU延迟(甚至高达1毫秒,具体取决于纹理大小、驱动程序和GPU)。前面说过,如果使用预缓存,应该没有问题。但是如果你使用的是“重字体”的场景,或者是区域化的语言场景(使用较多的字形比如中文),那么问题还是会出现。好消息是OpenGL3.0为这两个问题提供了解决方案,让你可以直接使用存储在像素中的属性来加载数据矩形。GL_UNPACK_ROW_LENGTH指定内存源数据的宽度。需要注意的是,这个属性会影响当前OpenGL上下文的全局状态。加载纹理时使用像素缓冲区对象(PBO)可以避免CPU延迟。与OpenGL中的所有缓冲区对象一样,PBO将驻留在GPU上,但也可以进行内存映射。PBO有很多有趣的属性,但我们比较关心的是一个属性,可以在主存取消映射关系后异步加载纹理。此时操作队列变为:glMapBufferRange→writeglyphstobuffer→glUnmapBuffer→glPixelStorei(GL_UNPACK_ROW_LENGTH)→glTexSubImage2D调用glTexSubImage2D可以立即返回,不会阻塞渲染器,字体渲染器可以将整个缓冲区映射到内存中,貌似是没问题。这是更新缓存纹理的一个很好的解决方案。这两种OpenGLES3.0的优化方式会出现在Android4.4中。阴影效果一般情况下,文本在渲染时都会带有阴影效果,这是一个比较耗费资源的操作。相邻字形可以相互模糊后,字体渲染器不进行独立的预模糊。实现模糊的方法有很多种,但是为了尽量减少在同一帧进行这些调整操作和纹理采样操作,阴影效果会简单地存储为纹理,在多帧切换时可以保存。由于应用程序很容易压垮GPU,我们仍然必须依靠CPU来模糊文本。最简单高效的方法是使用Renderscript的C++API,只需要几行代码就可以实现核心功能。最简单的方法是在初始化Renderscript时指定RS_INIT_LOW_LATENCY标志,强制它在CPU上运行。未来的优化操作有一个优化方法希望在我离开Android团队之前能够实现。文本预缓存、异步和部分纹理更新是一些重要的优化。但是光栅化文本符号一直是一个非常耗费资源的操作,在systrace中很容易看出这一点(启用gfs标志然后观察precacheText事件)。优化预缓存的一种简单方法是将此操作放在另一个工作线程上执行,而将光栅化操作放在后台。该技术已用于一些复杂的路径光栅化操作,但并未添加到OpenGL体系结构中。改进批处理和合并操作也是一种可能的优化。用于绘制文本的颜色通常被发送到片段阴影统一操作。这减少了发送到GPU的顶点数据量,但会产生许多不必要的批处理指令的副作用:一次批处理操作只能包含一种文本颜色。如果文本颜色也存储为顶点属性,则可以向GPU传递更少的数据。源码如果想详细查看字体渲染器的实现,可以浏览libhwui的GitHub,可以从FontRender.cpp入手,因为很多惊喜都发生在这里,其支持类可以在font或sub中找到目录。对了,PixelBuffer.cpp这个文件也不错,你可以看看。这是像素缓冲区的抽象实现,可用于CPU(uint8_t类型数组)或GPU缓冲区(PBO)。如果***,本文只是对Android字体渲染器的简单介绍。还有很多实现细节没有考虑到,或者很多问题后面会解释,有什么问题尽管问我。原文链接:medium翻译:chris翻译链接:http://blog.jobbole.com/70468/