字体渲染原理与TextMeshPro使用

最近用了一段时间的TextMeshPro,也是逐渐对其有一定了解以及自己的一些看法。先说说为什么最近项目切换了TextMeshPro呢?主要还是因为内存问题,项目中Unity版本2020.x,Unity想要升级的要话踩的坑确实有点多,没像Android那样平滑升级,所以切换版本的方案暂时pass掉,在Vulkan引擎下,如果使用Aria字体会偶现内存释放不掉,甚至达到600-800M的程度,一块FontTexture占用几百M的内存,这合理吗?很显然内存优化首当其冲的就是这块FontTexture!

文字渲染原理

早期文本渲染方式 —— BitMap Font

使用像OpenGL这样的底层库来把文本渲染到屏幕实际上非常困难,假设我们现在根本不清楚有什么库/工具把一个文字展示到屏幕上,而是从0到1的完成这件事情,你会怎么做?我说说我的看法:

1、首先得“造字”,并不是说得创造一个汉字或字符,而是对于一个字符来讲,用计算机怎么存储,有一种很简单的方案,那就是二维数组,比如10 * 10的大小,每一个元素用 0/1 表示一个像素单位,0表示黑,1表示白,那么我们是不是就实现了类似于Bitmap来存储数据的方式了呢?这似乎是个不错的方案!

2、汇集我们需要用到的全部字符,比如“A-Z,0-9,我、你、他……”等程序中需要用到的全部字符,一个字符都做成一个Bitmap并且编号,这样当需要显示 “A” 字符的时候,就去找编号为1的Bitmap,显示 “我” 的时候就去编号为 37 的Bitmap(举例)。当然也可以把这些图做成一张大图,直接按照固定大小分割成小格子即可,每次都是从这张大图里找到对应的小格子。

3、找到对应编号的图,渲染到你想要显示文本的区域即可,就像上图那样,是不是非常简单?

其实这种方案就是Bitmap Font(位图字体),想想这样的做法有什么优缺点吗? 首先,它相对来说很容易实现,并且因为位图字体已经预光栅化了,它的效率也很高。然而,这种方式不够灵活。当你想要使用不同的字体时,你需要重新编译一套全新的位图字体,而且你的程序会被限制在一个固定的分辨率。如果你对这些文本进行缩放的话你会看到文本的像素边缘。比如我需要很大的字体,那么对应的文字图也需要放大,因为我们的设定的大小是固定的 10 * 10的像素单位,放大了肯定模糊。

现代文本渲染 —— FreeType

说完了早期的字体渲染方式以及优缺点,下面看看更加灵活的现代文本渲染方式 —— FreeType。关于FreeType可以参考 《OpenGL 文本渲染》

简单来看,FreeType是一个能够用于加载字体并将他们渲染到位图C语言编写的库。FreeType最吸引人的点在于它能加载TrueType字体,也就是我们现在见到的以 .TTF 结尾的字体文件,由微软和苹果联合开发。所以得看看TrueType字体到底是怎么定义一个字符的?

TrueType字体不是用像素或其他不可缩放的方式来定义的,它是通过数学公式(曲线的组合)来定义的。类似于矢量图像,这些光栅化后的字体图像可以根据需要的字体高度来生成。通过使用TrueType字体,你可以轻易渲染不同大小的字形而不造成任何质量损失,这样就解决了位图字体缩放后会变模糊的问题。

具体怎么实现的呢?可以先进入这个网站看看 https://photopea.github.io/Typr.js/

这其中就存储着从二进制比特到屏幕像素的关键信息。

表项 说明
head 字体头 字体的全局信息
cmap 字符代码到图元的映射 把字符代码映射为图元索引
glyf 图元数据 图元轮廓定义以及网格调整指令
maxp 最大需求表 字体中所需内存分配情况的汇总数据
mmtx 水平规格 图元水平规格
loca 位置表索引 把元索引转换为图元的位置
name 命名表 版权说明、字体名、字体族名、风格名等等
hmtx 水平布局 字体水平布局星系:上高、下高、行间距、最大前进宽度、最小左支撑、最小右支撑
kerm 字距调整表 字距调整对的数组
post PostScript信息 所有图元的PostScript FontInfo目录项和PostScript名
PCLT PCL 5数据 HP PCL 5Printer Language 的字体信息:字体数、宽度、x高度、风格、记号集等等
OS/2 OS/2和Windows特有的规格 TrueType字体所需的规格集

其中hmtx就包含了如下信息:

每一个字形都放在一个水平的基准线(Baseline)上(即上图中水平箭头指示的那条线)。一些字形恰好位于基准线上(如’X’),而另一些则会稍微越过基准线以下(如’g’或’p’)(译注:即这些带有下伸部的字母,可以见这里)。这些度量值精确定义了摆放字形所需的每个字形距离基准线的偏移量,每个字形的大小,以及需要预留多少空间来渲染下一个字形。下面这个表列出了我们需要的所有属性。

属性 FreeType API获取方式 生成位图描述
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像素)

:::tip{title=“总结”} 所以总结来看,现代字体渲染方式其实就是通过TrueType或者OpenType等方案,存储字符的字体形状、轮廓等信息,然后通过一系列数学计算(比如贝塞尔曲线等)得出字体位图,然后渲染出来。

设想所以如果同一个字符需要展示出不同的大小,那么必然会重新走多次字体 -> 字体位图的过程:

1graph TD
2字体数据 --> 字形计算 --> 得到FontBitmap --> 渲染到Screen

:::

正是因为这个过程,所以在文本量比较大的时候,内存中就出现了大量的FontTexture,这些FontTexture正是通过计算得出来的字形Bitmap!如果引擎层可以自主释放话,那倒也还好,但是如果一旦出现释放不掉或者忘记释放的问题,那就存在了很严重内存泄漏问题。

TextMeshPro的基本原理

使用FreeType字体的问题是字形纹理是储存为一个固定的字体大小的,因此直接对其放大就会出现锯齿边缘。此外,对字形进行旋转还会使它们看上去变得模糊。这个问题可以通过储存每个像素距最近的字形轮廓的距离,而不是光栅化的像素颜色,来缓解。这项技术被称为有向距离场SDF (Signed Distance Fields)

其实这种概念在图形学中隐式曲面中也存在 —— 符号距离函数(Signed Distance Function) 几何形体可以通过距离函数来得到几何形体混合的效果,可以看看一个简单的示例: 对于这样一个二维平面的例子,定义空间中每一个点的 SDF 为该点到阴影区域右边界的垂直距离,在阴影内部为负,外部为正,因此对于A和B两种阴影来说的SDF分别如上图下半部分所示。有了 SDF(A)、SDF(B) 之后对这两个距离函数选择性的做一些运算得到最终的距离函数,这里采用最简单的 SDF = SDF(A) + SDF(B) 来举例,最终得到的 SDF 为零的点的集合即为混合之后的曲面,对该例子来说,就是两道阴影之间中点的一条线。

简单理解为屏幕中的每个像素点都需要与一个物体的当前点进行比较,在外部为正,在内部为负,在边缘为 0。这样就没有顶点插值,因为每个点都是使用距离来进行计算的。SDF 在放大缩小的情况下,它的距离也是按比例放大缩小的,图片或者文字的边缘距离屏幕上像素点是动态变化的,并且也会放大缩小。当文字放大时,这个像素距离仍然是比例放大,文字本身的像素不与周围的像素进行插值融合,而是原本自身的像素,所以看起来非常清晰,没有锯齿感。

TextMesh Pro中生成的 xxx.asset 字体文件里面包含一张图片,这张图片应该是矢量图,这张图片可以与 shader 尽情融合反应(意思是指 shader 可以影响这张图,进而影响TextMesh Pro中的任何字体),所以各种特炫酷的效果都能满足了,原来的 Text 是不可以这样做的。

SDF文件中每一个字形都表示成一组数学曲线描述的轮廓,它包含了字形边界上的关键点,连线的导数信息等,在显示字体时,渲染引擎通过读取其数学矢量,并进行一定的数学运算来实现渲染,字的内部则通过光栅化来填充。矢量字体的优点是存储空间小,可以无限缩放而不产生变形,缺点是显示系统复杂,需要很多操作才能显示出矢量资源,因而速度较慢,也不适用于一些硬件,比如对于算力低下的某些嵌入式设备。

TMP其实可以制作静态字体矢量图集,也可以制作动态字体矢量图集。TMP的动态字体的原理,虽然很方便,但是需要一定的计算量,下面是动态字体和静态字体的渲染流程:

1graph TD
2动态字体 --> 先找到xxx.ttf原生字体 --> 然后通过SDAFF算法,生成文字矢量图 -->  加载到动态字体xxx.asset的贴图里 --> 从贴图上取出文字显示在屏幕上面
3
4静态字体 --> 直接找到字符矢量图--> 从贴图上取出文字显示在屏幕上面

相比于静态字体,动态字体多了先找原生字体然后再计算矢量图的过程。

TextMeshPro的使用

基本使用这里不再赘述了,简单记录一下自己的使用方式。

如果源字体很大,首先做的就是需要裁剪源字体,如果能接受的话可以不用裁剪,否则这源字体占用内存就很高,首先使用环境:Python ( 工具4.x版本需要 Python 3.6+)

下面是通过pyftsubset来进行字体裁剪的命令,9000.txt是9000常用汉字与符号表,在GitHub也有一些开源项目中可以找到,比如 Unity-TextMeshPro-Chinese-Characters-Set

1pip install fonttools
2pyftsubset src.ttf --text-file=9000.txt --output-file=src_sub.ttf

通过这款工具件做一个精简字体,下面是参数说明:

  • src.ttf 要被裁剪精简的字体文件
  • text-file=9000.txt裁剪的字体中保留1.txt这个文档中的所有字符
  • output-file= 将裁剪后的字体保存为src_sub.ttf

然后看具体业务需求,静态字体矢量图集大概多少是合理的,一般来说如果只是显示固定的内容,那么把所有的固定文字打包成静态图集即可,选多少符号与文字就看具体业务需求。

然后设置动态图集,动态图集作为静态图集的failedback字体,这样的话即使遇到静态图集中没有的字符,也不至于显示一个方框。TMP_Settings默认设置将静态图集作为默认字体。

下面看看需要注意的点:

  • 动态字体、静态字体、还有原生字体、都需要单独打包,避免出现多份字体文件。
  • TMP 的 shader 统一放入 Resources 文件夹下,默认提前加载,字体在 Resources 存在一份静态字体和动态字体,作为主字体进行开发以及运行时展示。
  • TMP 的字体如果经常修改,最好是在其父节点添加一个 canvas 保证不影响其他UI。

:::tip{title=“提示”} 最后总结一下,如果用的新版本Unity,或者图形引擎选择的是OpenGL,其实不存在图集内存泄漏的问题,如果对默认字体或者字体特效没有什么要求的话直接用Text就足矣。如果但凡遇到字体有特效(比如阴影、外发光等)需求,而且字体大小经常改变,或者像我一样需要优化字体内存,那么TextMeshPro绝对是个不错的方案! :::

Reference

TrueType-Reference-Manual

OpenGL 文本渲染

Signed Distance Field

字体裁剪与精简(ttf/otf)