前置知识:了解 Activity 页面框架的基本概念(第 8 章),理解帧缓冲区的概念(第 5 章)。 本章目标:理解 CrossPoint Reader 如何在极有限的内存中实现高质量的中英文混排渲染,包括分块帧缓冲、字体架构、字形查找、字距调整、连字替换和两遍渲染优化。

9.1 GfxRenderer 概览

CrossPoint Reader 的渲染引擎集中在一个文件里——GfxRenderer.cpp,大约 46KB 的源码。对于一个嵌入式项目来说,这算是一个”大文件”了。它承担了所有把像素画到屏幕上的职责:
  • 画线、画矩形:UI 元素的基础
  • 画文字:中文、英文、日文等多语言渲染
  • 画图片:书籍封面、插图
  • 管理帧缓冲:在内存中维护一块”画布”
  • 处理旋转:支持横屏和竖屏
  • 灰度处理:在黑白屏上模拟灰度效果
你可以把 GfxRenderer 想象成一个画师——Activity 框架告诉他”在这里画一段文字,在那里画一张图”,他负责把这些指令变成帧缓冲区里的像素,最终由墨水屏驱动刷到屏幕上。

9.2 分块帧缓冲——对抗内存碎片

问题:连续内存找不到

第 5 章提到,800x480 分辨率的墨水屏需要一块约 48KB 的帧缓冲区。在电脑上,48KB 不值一提。但在 ESP32-C3 的 380KB RAM 里,这是一件棘手的事。 原因不是”内存不够”,而是内存碎片化。经过反复的 mallocfree,RAM 就像一块被反复挖洞的瑞士奶酪——空洞加起来有 50KB,但没有哪个单独的洞超过 8KB。你需要一块连续的 48KB,但系统告诉你:“抱歉,找不到。“

解决方案:拆成小块

CrossPoint Reader 的做法是把 48KB 的大缓冲区拆成多个 8KB 的小块,分散分配:
// lib/GfxRenderer/GfxRenderer.h
class GfxRenderer {
  static constexpr size_t BW_BUFFER_CHUNK_SIZE = 8000;  // 8KB 小块
  std::vector<uint8_t*> bwBufferChunks;                 // 多块拼接
};

// lib/GfxRenderer/GfxRenderer.cpp
bool GfxRenderer::storeBwBuffer() {
  for (size_t i = 0; i < bwBufferChunks.size(); i++) {
    const size_t offset = i * BW_BUFFER_CHUNK_SIZE;
    const size_t chunkSize = std::min(BW_BUFFER_CHUNK_SIZE, frameBufferSize - offset);
    bwBufferChunks[i] = (uint8_t*)malloc(chunkSize);
    if (!bwBufferChunks[i]) {
      freeBwBufferChunks();  // 某块分配失败就全部释放
      return false;
    }
    memcpy(bwBufferChunks[i], frameBuffer + offset, chunkSize);
  }
  return true;
}
逐行看这段代码:
  1. 按 8KB 一块遍历所有分块
  2. 为每块调用 malloc 申请内存——这些小块不需要连续,可以散落在 RAM 的各个角落
  3. 如果某一块分配失败,说明连 8KB 都凑不出来了,此时”全军撤退”——释放已经分配的所有块
  4. 分配成功后把帧缓冲的数据复制进小块
类比:这就像搬家——你有一个大衣柜要搬出去,但电梯太小放不下。怎么办?把衣柜拆成几块小零件,分几次搬运。到了新家再按顺序拼起来。虽然多了拆装的步骤,但至少能搬得动。
渲染时要访问帧缓冲的某个位置,只需要做一次简单的除法:
块编号 = 偏移量 / 8000
块内偏移 = 偏移量 % 8000
这点额外计算对 160MHz 的 CPU 来说微不足道,但换来了在碎片化内存中也能工作的能力。

9.3 字体架构——15 个字体变体

字体家族

CrossPoint Reader 提供三个字体家族,四种尺寸,再加上 UI 用字体和小字体:
3 个字体家族 x 4 个尺寸 = 12 个阅读字体 + 2 个 UI 字体 + 1 个小字体

Noto Serif   (衬线)        -- S(12pt) / M(14pt) / L(16pt) / XL(18pt)
Noto Sans    (无衬线)      -- S / M / L / XL
OpenDyslexic (阅读障碍友好) -- S / M / L / XL
衬线体(Serif)就是笔画末端有装饰的字体,比如宋体。适合长文阅读,因为衬线能帮助眼睛沿着文字行移动。 无衬线体(Sans-serif)笔画干净利落,没有装饰,比如黑体。在小尺寸下更清晰。 OpenDyslexic 是一款专为阅读障碍人群设计的字体,每个字母的底部更粗重,减少”字母翻转”的错觉。在一个开源阅读器里提供这样的字体,是一种值得称赞的无障碍设计理念。

字体存储

15 个字体变体包含大量的字形数据(汉字动辄几千个),全部放在 RAM 里是不可能的。CrossPoint Reader 把所有字体压缩后存储在 Flash(16MB)中,运行时按需解压到 RAM。这就像一个图书馆:所有书都存放在书架上(Flash),你只把当前要读的几本搬到桌上(RAM)。

9.4 字形查找——Unicode 区间与二分搜索

问题:怎么从几万个字形中找到一个?

Unicode 字符集非常庞大。光常用汉字就有几千个,加上拉丁字母、日文假名、标点符号等,一个字体可能包含上万个字形。如何快速找到某个字符对应的字形?

解决方案:区间索引 + 二分搜索

CrossPoint Reader 不是用一个巨大的数组来存所有字形,而是把字符编码按连续区间组织。例如:
区间 1: U+0020 ~ U+007E  (基本拉丁字母 + 标点)
区间 2: U+4E00 ~ U+9FFF  (CJK 统一汉字)
区间 3: U+3040 ~ U+309F  (日文平假名)
...
查找时用二分搜索定位到正确的区间,再通过简单的偏移量算出字形位置:
// lib/EpdFont/EpdFont.cpp
const EpdGlyph* EpdFont::getGlyph(uint32_t cp) const {
  const auto it = std::upper_bound(intervals, end, cp,
    [](uint32_t value, const EpdUnicodeInterval& interval) {
      return value < interval.first;
    });
  if (it != intervals) {
    const auto& interval = *(it - 1);
    if (cp <= interval.last) {
      return &data->glyph[interval.offset + (cp - interval.first)];
    }
  }
  return getGlyph(0xFFFD);  // 找不到 -> 返回替换字符
}
这段代码的逻辑是:
  1. std::upper_bound 做二分搜索,找到第一个”起始编码大于目标字符”的区间
  2. 往前退一步,就是目标字符可能所在的区间
  3. 检查目标字符是否在这个区间的范围内(cp <= interval.last
  4. 如果在,用简单的减法 cp - interval.first 计算出它在区间内的偏移量
  5. 如果不在任何区间里,返回 Unicode 替换字符 U+FFFD(就是你经常见到的那个 ”�” 方块)
这种设计的时间复杂度是 O(log n)——区间数量翻倍时,搜索只多一步。对比逐个遍历的 O(n),在处理上万字符的 CJK 字体时优势明显。

9.5 字距调整(Kerning)

什么是 Kerning?

如果你仔细观察印刷品上的 “AV” 和 “AB”,会发现 “AV” 两个字母靠得更近。这不是错误,而是刻意的设计——因为 A 的右侧是斜面,V 的左侧也是斜面,中间会留出一个三角形的空白。如果不调整间距,看起来就会像 A 和 V 之间有一条白缝。 Kerning(字距调整)就是根据相邻字符的形状微调它们之间的距离。

CrossPoint Reader 的实现

// lib/EpdFont/EpdFont.cpp
int8_t EpdFont::getKerning(uint32_t leftCp, uint32_t rightCp) const {
  if (!data->kernMatrix) return 0;
  const uint8_t lc = lookupKernClass(data->kernLeftClasses, ..., leftCp);
  const uint8_t rc = lookupKernClass(data->kernRightClasses, ..., rightCp);
  if (lc == 0 || rc == 0) return 0;
  return data->kernMatrix[(lc - 1) * data->kernRightClassCount + (rc - 1)];
  // 返回值是 4.4 定点数(整数 4 位 + 小数 4 位)
}
这段代码使用了类矩阵(class-based kerning)方案:
  1. 将所有字符分成若干”左类”和”右类”——形状相似的字符归为一类(比如 A 和 Lambda 可能同属一个左类)
  2. 用一个二维矩阵存储每对”左类-右类”之间的间距调整值
  3. 查找时先确定两个字符各自的类别,再到矩阵里查值
这比为每对字符单独存储 kerning 值要节省大量空间——几万个字符只需要几十个类别。

定点数:用整数模拟小数

注意返回值是 4.4 定点数,这是什么? 在嵌入式系统中,浮点运算(floatdouble)通常比整数运算慢得多(ESP32-C3 没有硬件浮点单元,所有浮点运算都是软件模拟的)。但是 kerning 的精度需要小数——字母间距调整 0.5 个像素是常见的需求。 解决办法是定点数(Fixed-Point Number):用一个 8 位整数来表示小数。高 4 位是整数部分,低 4 位是小数部分。
值 = 整数部分 + 小数部分 / 16

例如:
  0x15 = 0001 0101
  整数部分 = 0001 = 1
  小数部分 = 0101 = 5
  实际值 = 1 + 5/16 = 1.3125 像素

  0xFE = 1111 1110(有符号,即 -2)
  表示向左缩 2/16 = 0.125 像素
精度到 1/16 像素,对于一块 200 DPI 的墨水屏来说绰绰有余——而整个运算过程只用了一次数组查表和位操作,没有任何浮点运算。

9.6 连字替换(Ligature)

什么是连字?

在英文排版中,某些相邻字母组合在一起会变成一个特殊的符号。最常见的例子:
  • “fi” 合并成 “fi”——因为 f 的顶部横杠和 i 的圆点会碰撞
  • “fl” 合并成 “fl”
  • “ffi” 合并成 “ffi”
这些合并后的符号叫做连字(Ligature)。它们让文字看起来更加自然流畅。

CrossPoint Reader 的实现

// lib/EpdFont/EpdFont.cpp
uint32_t EpdFont::applyLigatures(uint32_t cp, const char*& text) const {
  while (true) {
    const auto saved = text;
    const uint32_t nextCp = utf8NextCodepoint(&text);
    if (nextCp == 0) break;
    const uint32_t lig = getLigature(cp, nextCp);
    if (lig == 0) { text = saved; break; }
    cp = lig;  // 连字本身可以继续链式替换
  }
  return cp;
}
这段代码的巧妙之处在于链式替换
  1. 拿到当前字符 cp,再读取下一个字符 nextCp
  2. 查找这两个字符是否构成连字
  3. 如果构成,把连字结果作为新的 cp继续尝试和下一个字符合并
  4. 这样 “ffi” 的处理过程是:f + f = ff(连字),ff + i = ffi(连字),最终变成一个字形
如果没有找到连字,通过 text = saved 把指针回退——“看了一下,不需要合并,假装没看过。“

9.7 两遍渲染——核心优化

这是 CrossPoint Reader 渲染系统中最精妙的设计,值得详细拆解。

问题:几千个汉字字形放不进内存

一页中文文本可能包含几百个不同的汉字。每个汉字字形压缩存储在 Flash 中,要显示就得解压到 RAM。但解压所有可能用到的字形(几千个)需要的内存远超系统可用量。 一个朴素的方案是”用到一个解压一个”——但这样每个字形要单独启动一次解压流程,效率很低,因为字形在 Flash 中是按分组压缩的:一组可能包含几十个字形,解压一组才能拿到其中一个,下次需要同组的另一个字形又得重新解压整组。

解决方案:先扫描,再批量解压,最后渲染

CrossPoint Reader 把渲染过程分成两遍
// lib/GfxRenderer/FontCacheManager.cpp

// 第一遍:扫描模式 -- drawText() 不画,只记录需要哪些字
PrewarmScope::PrewarmScope(FontCacheManager& manager) : manager_(&manager) {
  manager_->scanMode_ = ScanMode::Scanning;
  manager_->scanText_.clear();
  manager_->scanText_.reserve(2048);
}

void FontCacheManager::recordText(const char* text, int fontId, EpdFontFamily::Style style) {
  scanText_ += text;  // 累积所有需要渲染的文本
}

// 两遍之间:批量解压需要的字形
void PrewarmScope::endScanAndPrewarm() {
  manager_->scanMode_ = ScanMode::None;
  manager_->prewarmCache(manager_->scanFontId_, manager_->scanText_.c_str(), styleMask);
  // 1. 去重所有字形 -- 最多 512 个
  // 2. 找出涉及哪些压缩分组
  // 3. 逐组解压,只提取需要的字形
  // 4. 存入 PageSlot 缓存,按 glyphIndex 排序供二分搜索
}

// 使用方式:
auto scope = fcm->createPrewarmScope();
renderPage(...);             // 第一遍:只扫描
scope.endScanAndPrewarm();   // 集中解压
renderPage(...);             // 第二遍:真正绘制
让我们拆解每一步: 第一遍(扫描):调用 renderPage(),但渲染引擎处于”假装在画”的状态——所有 drawText() 调用都不会真正画像素,而是把文本内容记录到 scanText_ 字符串里。这一遍的目的是收集购物清单 中间步骤(批量解压)endScanAndPrewarm() 拿到了完整的文本列表,然后:
  • 去重:一页文本里 “的” 可能出现 20 次,但只需要解压一次
  • 分组:确定这些字形分布在 Flash 中的哪些压缩分组里
  • 解压:对每个相关的分组只解压一次,从中提取需要的字形
  • 缓存:把字形数据存入缓存,按索引排序以便后续二分搜索
第二遍(渲染):再次调用 renderPage(),这次是真正地画像素。所有需要的字形已经在缓存里了,每次 drawText() 直接从缓存中获取字形数据,不再需要访问 Flash 或做任何解压。
类比:想象你要做一桌年夜饭。 朴素方案:做一道菜、发现缺葱,跑一趟超市;做下一道菜、发现缺酱油,又跑一趟超市。来回跑十几趟。 两遍方案:先把所有菜谱过一遍,列出完整的购物清单(第一遍)。去超市一次性买齐所有食材(批量解压)。回家安心做饭(第二遍)。
这个设计的代价是渲染过程要执行两次,但第一遍只做字符串拼接,开销很小。换来的好处是:
  • 每个压缩分组只解压一次,大幅减少 Flash 读取和解压次数
  • 内存峰值可控——只缓存当前页需要的字形(最多 512 个)
  • 解压完成后,第二遍的渲染速度非常快

9.8 抗锯齿原理

为什么文字边缘会有锯齿?

墨水屏的每个像素只有”黑”和”白”两种状态。当一条斜线或曲线经过像素网格时,像素要么整个变黑,要么整个留白——没有”半黑”的选项。这就导致了阶梯状的锯齿。

灰度抗锯齿:模拟”部分覆盖”

如果能显示灰度(介于黑和白之间的颜色),就可以用灰色像素来”模糊”锯齿边缘:
无抗锯齿:          有抗锯齿:
  ██                  ██
  ██                ▓▓██
████              ▒▒████
████              ░░████

██ = 黑色(完全覆盖)
▓▓ = 深灰(大部分覆盖)
▒▒ = 中灰(部分覆盖)
░░ = 浅灰(少量覆盖)
眼睛会把这些灰度像素”脑补”成平滑的边缘。

4-bit 灰度:性价比之选

CrossPoint Reader 使用 4-bit(16 级)灰度来做抗锯齿。为什么不用 8-bit(256 级)?因为:
  • 墨水屏本身只能显示有限的灰度级别(通常 4-16 级)
  • 16 级灰度对字体渲染来说已经足够——人眼很难区分更多级别
  • 4-bit 比 8-bit 节省一半存储空间

小字号的例外

不是所有情况都适合抗锯齿。小字号(像素高度 16px 以下)不宜开启抗锯齿。原因很直觉:字号越小,每个笔画占的像素越少。一个只有 1-2 像素宽的笔画如果再被灰度”模糊”,就会变得发虚、看不清楚。这时候,“锐利的锯齿”反而比”模糊的平滑”更好读。 这是排版领域的一条经验法则:大字号追求平滑,小字号追求清晰。CrossPoint Reader 对不同字号使用不同的抗锯齿策略,体现了在细节上的打磨。

9.9 把这一切串起来

一次完整的页面渲染,串联了本章介绍的所有子系统:
Activity 请求渲染一页内容
    |
    v
[ 第一遍:扫描 ] -- drawText() 只记录文本,不画像素
    |
[ 批量解压字形 ] -- 去重、分组、解压、缓存
    |
[ 第二遍:渲染 ] -- 从缓存取字形 → 连字替换 → 字距调整 → 抗锯齿 → 写入帧缓冲
    |
帧缓冲区就绪,交给墨水屏驱动刷屏
帧缓冲是分块存储的(9.2),字形查找用二分搜索(9.4),字符间距经过 kerning 调整(9.5),相邻字符可能被合并成连字(9.6),像素值可能包含灰度抗锯齿信息(9.8)。这些子系统协同工作,让 380KB 内存的芯片渲染出接近专业排版品质的页面。

本章要点

  1. GfxRenderer 是 CrossPoint Reader 的核心渲染引擎,负责所有像素级别的绘制工作。
  2. 分块帧缓冲把 48KB 的大缓冲区拆成 8KB 小块分散分配,解决了内存碎片化导致的大块连续内存分配失败问题。
  3. 字体架构包含 3 个字体家族、4 种尺寸共 15 个变体。字体压缩存储在 Flash 中,按需解压到 RAM。
  4. 字形查找采用 Unicode 区间索引加二分搜索,实现 O(log n) 的快速定位。找不到的字符回退到替换字符 U+FFFD。
  5. **字距调整(Kerning)**使用类矩阵方案和 4.4 定点数,以纯整数运算实现亚像素精度的字间距微调。
  6. **连字替换(Ligature)**支持链式合并,如 “ffi” 可以一步步合并成单个字形。
  7. 两遍渲染是最关键的优化:第一遍扫描收集所有需要的字形,批量解压后第二遍真正渲染。避免了重复解压和内存峰值过高的问题。
  8. 抗锯齿使用 4-bit(16 级)灰度模拟边缘平滑,但小字号下会关闭以保持清晰度。

下一章预告:第 10 章将跳出渲染系统,从全局视角审视 CrossPoint Reader 的内存管理策略——380KB 的 RAM 是如何被精密分配的,六大优化策略如何协同工作,让一个完整的电子书阅读器在极小的内存空间里运转自如。