3、字体压缩技术:点阵字体与矢量字体、字体子集化、字体渲染优化、自定义字体格式

嵌入式GUI里,字体这块儿,说实话,是很多项目后期才想起来要优化的。我见过不少团队,功能都调通了,结果发现字库占了一半的Flash空间,最后不得不砍动画效果来腾地方。今天咱们就把字体压缩这件事儿彻底聊透。

3.1 点阵字体 vs 矢量字体:选哪个?

先搞清楚这两个东西的本质区别。点阵字体,说白了就是一张张像素图。每个字符对应一个固定大小的位图,比如16x16、24x24。矢量字体存的则是数学曲线,用贝塞尔曲线描述字形轮廓,渲染时再实时计算像素。

对比维度 点阵字体 矢量字体
存储方式 像素矩阵 贝塞尔曲线+控制点
缩放效果 放大锯齿明显 任意缩放不失真
渲染速度 极快(直接拷贝像素) 较慢(需要光栅化)
内存占用 固定大小,多尺寸需多套 一套数据,多尺寸复用
适用场景 低分辨率屏、固定字号 高分辨率屏、动态字号

我在一个智能手表项目里就踩过坑。屏幕只有1.3寸,分辨率160x128,团队选了矢量字体,结果每次刷新时间显示都要卡顿一下。后来换成点阵字体,渲染速度直接提升了5倍。所以我的建议是:屏幕分辨率低于200PPI,且字号固定不变,优先用点阵。反之,如果屏幕是高清屏,或者用户能调字体大小,那就老老实实用矢量。

我的小技巧: 点阵字体可以只存常用字号,比如12px、16px、24px三档。中间尺寸用最近邻插值,虽然有点锯齿,但人眼在嵌入式小屏上其实看不太出来。这样能省掉一半以上的点阵数据。

3.2 字体子集化:只留你需要的字符

你想想看,一个完整的宋体字库,GB2312就有6763个汉字,加上ASCII符号,轻松超过1MB。但你的产品可能只需要显示时间、温度、几个菜单项,总共不到200个字符。剩下的99%都是浪费。

字体子集化,就是只从原字库中提取你实际用到的字符,生成一个精简版。我常用的工具有两个:fonttools(Python库)和pyftsubset(命令行工具)。

# 用 pyftsubset 提取子集
pyftsubset NotoSansSC-Regular.ttf \
  --text="0123456789:.-℃%ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" \
  --output-file=NotoSansSC-Subset.ttf \
  --flavor=woff \
  --layout-features='*'

这个命令只保留了数字、冒号、点、百分号、温度符号和大小写字母。原来2.3MB的字体,子集化后只有28KB。嗯,这里要注意:--text参数里一定要包含所有可能出现的字符,包括空格和标点。我曾经漏了空格,结果界面上所有文字都挤在一起,排查了半天。

核心原则: 子集化不是压缩,是裁剪。它不改变字体质量,只是扔掉你不需要的部分。所以对显示效果零影响。

如果你的项目支持多语言,比如中文+英文+俄文,可以分别对每种语言做子集化,然后合并。但要注意不同字体的字形风格要统一,否则界面会显得很乱。

3.3 字体渲染优化:让文字更清晰

矢量字体渲染慢,主要慢在光栅化这一步。把数学曲线变成像素,需要做抗锯齿、hinting(微调)等操作。在嵌入式平台上,CPU资源有限,得想办法优化。

我常用的优化手段有这几个:

  • 预渲染缓存:把常用字符提前渲染成位图,存到内存里。显示时直接拷贝,不用再算。我习惯在初始化时把ASCII字符和数字全部预渲染好,中文按需渲染并缓存。
  • 降低抗锯齿等级:全屏抗锯齿(4x4采样)效果最好,但计算量也最大。在320x240以下的屏幕上,2x2采样人眼基本看不出区别,速度能快一倍。
  • 关闭不必要的hinting:Hinting是为了让小字号更清晰,但会大幅增加渲染时间。如果你的屏幕分辨率够高(比如300PPI以上),可以关掉hinting,让FreeType用原生渲染。
// FreeType 渲染配置示例
FT_Library  library;
FT_Face     face;

FT_Init_FreeType(&library);
FT_New_Face(library, "font.ttf", 0, &face);
FT_Set_Pixel_Sizes(face, 0, 16);

// 关闭 hinting,使用原生渲染
FT_Load_Char(face, 'A', FT_LOAD_NO_HINTING | FT_LOAD_TARGET_NORMAL);

// 或者使用轻量级抗锯齿
FT_Load_Char(face, 'B', FT_LOAD_TARGET_LIGHT);
避坑指南: 我曾经在STM32F4上开了全抗锯齿+hinting,结果渲染一个汉字要3ms。界面有20个汉字,光字体渲染就占了60ms,帧率直接掉到15fps。后来改成预渲染+2x2抗锯齿,渲染时间降到0.5ms,帧率回到60fps。所以一定要实测,别想当然。

3.4 自定义字体格式:为嵌入式量身定制

标准TrueType/OpenType格式,里面包含了很多嵌入式平台用不到的信息,比如字形名称、版权信息、复杂的布局表。这些冗余数据完全可以去掉。

我参与过一个项目,自己定义了一种极简字体格式,叫它EFont吧。结构非常简单:

// EFont 极简字体格式
typedef struct {
    uint32_t magic;          // 魔数,用于校验
    uint16_t num_glyphs;     // 字形数量
    uint16_t height;         // 字体高度
    uint32_t index_offset;   // 索引表偏移
    uint32_t data_offset;    // 字形数据偏移
} EFontHeader;

// 每个字形的索引项
typedef struct {
    uint32_t unicode;        // Unicode编码
    uint32_t offset;         // 数据偏移
    uint32_t size;           // 数据大小
    uint16_t width;          // 字形宽度
    uint16_t height;         // 字形高度
} EFontGlyphIndex;

这个格式去掉了所有元数据,只保留字形数据和索引。同样的字符集,比标准TTF小了40%。而且解析代码只有200行,不依赖FreeType,非常适合裸机环境。

当然,自定义格式也有代价:你得自己写一个PC端的转换工具,把TTF转成EFont。而且不支持复杂的排版特性,比如连字、竖排。如果你的产品只需要显示简单的文本,这个方案性价比很高。

我的建议: 如果团队人力充足,可以开发自定义格式,把字体压缩到极致。如果赶工期,就用标准格式+子集化,效果也不差。别为了炫技而过度优化,项目按时交付才是硬道理。

3.5 知识体系总览

下面这张图,把字体压缩的整个决策流程串起来了。你可以把它当成一个检查清单,做项目时对着走一遍,基本不会漏掉关键点。

字体压缩技术决策流程 字体选择 点阵字体 矢量字体 固定字号存储 最近邻插值缩放 字体子集化 渲染优化 字体压缩 自定义格式 子集化+标准格式 预渲染位图缓存 部署到嵌入式设备

从图上能看出来,整个流程是分叉再汇聚的。先根据屏幕和字号需求选字体类型,然后分别做子集化或渲染优化,最后统一做压缩和部署。每一步都有取舍,没有银弹。

好了,字体压缩这块儿就聊到这儿。核心就三句话:点阵省CPU费存储,矢量省存储费CPU,子集化永远是最划算的优化。下个项目里,记得先算算你的字库占了多少空间,再决定怎么搞。