第12章:渲染到电子墨水屏——数据如何在屏幕上定格

上一章给每个元素记好了坐标,这章把它们变成笔画。但电子墨水屏刷一次要数百毫秒,真正的艺术是取舍。

🎨

本章导师:达芬奇

核心方法论:艺术与工程,为每个像素负责

「达芬奇画的是光影,工程师画的是像素。上一章我们把每个元素的位置都算好了,这一章的任务只是"照着坐标落笔"——听起来不难,对吧?可真正的挑战藏在落笔之后:电子墨水屏不是画布,它刷一次要几百毫秒,刷完还要定格给你看一整页。于是每一帧都在考验一个取舍——这页是只有黑白,还是要掺进灰阶?纯黑白翻页飞快,一旦出现灰阶就得整屏慢刷。别小看这一问,它就是电子墨水屏的灵魂,也是"艺术与工程"交界的那个点。」

12.1 渲染模型:一页就是"一串带 y 坐标的元素"

翻页时,负责渲染(说人话:把内存里的画面数据变成屏幕上的显示,也就是把排好的内容"画出来")的 EpubReader::render() 只做一件事:调用 parser->render_page(state.current_page, renderer, epub) 画当前这一页。而 render_page 的核心逻辑非常轻:先 clear_screen() 清空帧缓冲,然后让 pages.at(page_index) 自己把元素逐个画出来。Page::render 就是一个遍历——对 elements 里的每个 PageElement 调用它的 render(renderer, epub)。这正是 README 说的那句话:"Rendering each page is trivial - we know the y position of each element on the page."(渲染每页是琐碎的——我们已经知道每个元素在页面上的 y 位置。)分页阶段把最难的排版做完了,渲染只是执行。

元素各自知道怎么画自己:PageLine 把"来自哪个块、第几行"翻译成一次文本渲染;PageImage 把图片按已算好的坐标(说人话:画面上的位置编号,用横向 x、纵向 y 两个数就能定位一个点)画出来。这套"接口 + 多态"的设计让 Page 完全不需要关心元素种类——加一种新元素(比如表格)时,只要它实现 PageElement::render,分页和渲染的骨架都不用动。看 lib/Epub/RubbishHtmlParser/Page.h 里的真实代码。

/* Page.h:每个元素都自带"怎么画自己" */
void Page::render(Renderer *renderer, Epub *epub) {
  for (auto element : elements) {
    element->render(renderer, epub);
  }
}

// PageLine:画一行文字(line_break_index 指定块内第几行)
void PageLine::render(Renderer *renderer, Epub *epub) {
  block->render(renderer, line_break_index, 0, y_pos);
}

// PageImage:画一张图片(x 已居中算好,画在 y_pos)
void PageImage::render(Renderer *renderer, Epub *epub) {
  block->render(renderer, epub, y_pos);
}

render_page 还有两个值得注意的细节。其一,clear_screen() 之后,若 renderer->has_gray() 为真,会先 flush_display() 一次——这是 EPDiy 专用的小技巧:上一帧如果画过灰阶内容,先整屏慢刷一次把残留清干净,再画本页。其二,当请求的页码越界(翻过全书最后一页)时,pages.at() 会抛 std::out_of_range,代码捕获后在屏幕中央画一个提示框。这两处体现了防御式编程。

/* RubbishHtmlParser::render_page —— 真正画一页 */
void RubbishHtmlParser::render_page(int page_index, Renderer *renderer, Epub *epub) {
  renderer->clear_screen();                // 清空帧缓冲
  if (renderer->has_gray()) {
    renderer->flush_display();             // 上一帧有灰阶 → 先慢刷清场
  }
  try {
    pages.at(page_index)->render(renderer, epub);
  } catch (const std::out_of_range &oor) {
    // 翻过最后一页:画提示框
    renderer->draw_text_box(
        "Reached the limit of the book\nUse the SELECT button",
        10, y, renderer->get_page_width(), 80, false, false);
  }
}
达芬奇提示

体会"分页记账、渲染执行"的分工:第 11 章算的是这页该有什么,本章做的是怎么把东西画出来。正是这种解耦,让 test/ 里跑分页单元测试时完全不需要屏幕,也让 ConsoleRenderer 可以把页面"打印到串口"来调试渲染逻辑。画布可以换,而内容与布局自岿然不动。

12.2 文字逐词绘制,图片居中绘制

文字渲染的关键在于:第 10 章换行时已经把每个单词的 x 坐标算好了(存在 word_xpos[] 里),渲染只是把它们读出来。看 TextBlock::render 的真实实现:先用 line_break_index 反推出这一行的起止单词下标 start/end,然后逐个单词调用 draw_text(x_pos + word_xpos[i], y_pos, words[i], ...)。这样两端对齐(JUSTIFIED)行被拉开的字间距、居中行被推算的起始 x,都已经"固化"在 word_xpos 里,绘制时无需再算。

/* TextBlock::render —— 逐词绘制某一行 */
void TextBlock::render(Renderer *renderer, int line_break_index, int x_pos, int y_pos) {
  int start = line_break_index == 0 ? 0 : line_breaks[line_break_index - 1];
  int end   = line_breaks[line_break_index];
  for (int i = start; i < end; i++) {
    uint8_t style = word_styles[i];   // 该词的粗体/斜体标记
    renderer->draw_text(x_pos + word_xpos[i], y_pos,
                        words[i], style & BOLD_SPAN, style & ITALIC_SPAN);
  }
}
// word_xpos[i] 在换行布局时已算好 —— 渲染只是"读坐标"

当这一层调用抵达 EpdiyFrameBufferRenderer::draw_text 时,坐标还要做最后一次换算:页面里存的 y 被当作"该行的顶部",绘制时再加上一行行高(把基线压下来)和 margin_topx 则加上 margin_left。然后按粗斜体从四种字体里挑出对应字体,交给 EPDiy 的 epd_write_string 把字符串写进帧缓冲。注意 &xpos&ypos 传的是指针——调用后它们会被推进到字符串的末尾,方便连续书写。

/* EpdiyFrameBufferRenderer::draw_text —— 换算坐标后落笔 */
void draw_text(int x, int y, const char *text, bool bold, bool italic) {
  int ypos = y + get_line_height() + margin_top;  // 页内 y 是"行顶"
  int xpos = x + margin_left;
  epd_write_string(get_font(bold, italic), text, &xpos, &ypos,
                   m_frame_buffer, &m_font_props);
}

/* 四种字体按粗斜体组合挑选 */
const EpdFont *get_font(bool is_bold, bool is_italic) {
  if (is_bold && is_italic) return m_bold_italic_font;
  if (is_bold)             return m_bold_font;
  if (is_italic)           return m_italic_font;
  return m_regular_font;
}

图片的绘制则多一步"清场"。ImageBlock::render 在真正画图前,先用一个纯白矩形(fill_rect(..., 255))盖住图片区域——这是为了擦掉上一页残留的文字像素(说人话:屏幕上最小的点,整个画面就是由无数这样的点拼出来的);接着 flush_area 把这一小块局部刷新上屏(保证残影立刻消失),最后 draw_image 按分页时算好的 x_pos/y_pos/width/height 画图。解码 JPEG 还是 PNG 由 Renderer::get_image_helper 根据文件扩展名或文件头魔数自动判断;若解码失败,会退化成画一个占位矩形,保证页面不至于空缺。

/* ImageBlock::render —— 先盖白再画图 */
void ImageBlock::render(Renderer *renderer, Epub *epub, int y_pos) {
  uint8_t *image_data = epub->get_item_contents(m_src, &image_data_size);
  renderer->fill_rect(x_pos, y_pos, width, height, 255);  // 盖掉旧残影
  renderer->flush_area(x_pos, y_pos, width, height);        // 局部快刷
  renderer->draw_image(m_src, image_data, image_data_size,
                       x_pos, y_pos, width, height);
  free(image_data);
}
元素绘制入口关键 API
文字TextBlock::renderdraw_textepd_write_string
图片ImageBlock::renderfill_rect + flush_area + draw_image
图形(提示框/电池)各渲染器直接调用draw_rect / fill_rect / draw_text_box

12.3 EPDiy:帧缓冲与刷新 API

所有绘制最终都落在 EPDiy 的帧缓冲上。在 EpdiyRenderer 的构造函数里完成屏幕的"上电三部曲":epd_init(EPD_OPTIONS_DEFAULT) 初始化硬件;epd_hl_init(EPD_BUILTIN_WAVEFORM) 建立高层状态机并选择内置波形;epd_hl_set_all_white(&m_hl) 先把整屏刷白。随后 m_frame_buffer = epd_hl_get_framebuffer(&m_hl) 拿到帧缓冲指针——此后所有绘制函数(epd_write_stringepd_fill_rect 等)都只是往这块内存里写数据,真正的上屏要等刷新调用。

/* EpdiyRenderer 构造:启动屏幕、拿帧缓冲 */
epd_init(EPD_OPTIONS_DEFAULT);
m_hl = epd_hl_init(EPD_BUILTIN_WAVEFORM);       // 内置波形
epd_hl_set_all_white(&m_hl);                    // 首刷全白
m_frame_buffer = epd_hl_get_framebuffer(&m_hl);

/* 整屏刷新:有灰阶走 GC16(慢),纯黑白走 DU(快) */
void flush_display() {
  epd_hl_update_screen(&m_hl, needs_gray_flush ? MODE_GC16 : MODE_DU,
                       temperature);
  needs_gray_flush = false;
}

/* 局部刷新:只更新一个矩形区域(如图片区) */
void flush_area(int x, int y, int width, int height) {
  epd_hl_update_area(&m_hl, MODE_DU, temperature,
                     {.x = x, .y = y, .width = width, .height = height});
}

帧缓冲的尺寸是一个值得记住的数字:EPD_WIDTH * EPD_HEIGHT / 2 字节——也就是说它每个像素只占 4 位(4 比特/像素,16 级灰阶)。因此 clear_screen() 只需一条 memset 就能把整屏刷成纯色;dehydrate() 保存睡眠前画面时也是拿这块缓冲用 miniz 压缩后写盘。另一个容易踩坑的点是旋转:面板物理上是横向的,代码在构造时调用 epd_set_rotation(EPD_ROT_INVERTED_PORTRAIT) 转成竖屏,于是页面宽高是"互换"的——get_page_width() 返回 EPD_HEIGHT - (margin_left + margin_right)get_page_height() 返回 EPD_WIDTH - (margin_top + margin_bottom)。若忘了这层旋转,坐标会整整偏转 90°。

/* 帧缓冲:EPD_WIDTH×EPD_HEIGHT/2 字节 = 4 位/像素的灰度缓冲 */
void clear_screen() {
  memset(m_frame_buffer, 0xFF, EPD_WIDTH * EPD_HEIGHT / 2);
}

/* 注意旋转:面板是横向的,页面宽高与 EPD 宽高互换 */
int get_page_width()  { return EPD_HEIGHT - (margin_left + margin_right); }
int get_page_height() { return EPD_WIDTH  - (margin_top  + margin_bottom); }

整个刷新流程由 main.cpp 的主循环驱动:用户按键 → 各 handle* 函数重画页面 → 画完电池图标与触摸反馈后调用一次 renderer->flush_display()。也就是说,EPDiy 采用的是"整页画进帧缓冲 → 一次性上屏"的模型:绘制期间屏幕纹丝不动,只有那一下刷新才真正改变画面。这对程序员很友好——不用考虑逐行扫描的时序,画错了擦掉重画即可。

数据 / API值或说明
帧缓冲EPD_WIDTH × EPD_HEIGHT / 2 字节,4 位/像素灰度
清屏memset(fb, 0xFF, ...) 一条清整屏
旋转epd_set_rotation(EPD_ROT_INVERTED_PORTRAIT)
页面宽/高EPD_HEIGHT − 左右边距 / EPD_WIDTH − 上下边距
整屏刷新epd_hl_update_screen(GC16 或 DU, temperature)
局部刷新epd_hl_update_area(MODE_DU, rect)
注意

刷新函数都接收 temperature(当前温度)参数——EPDiy 用它选择适配低温的波形,因为电子墨水在寒冷时离子迁移变慢,刷新质量会明显下降。这个项目里 Renderertemperature 字段默认 20(摄氏度)。真机验证时,若发现冬天翻页有"重影"或"刷不净",优先怀疑温度参数而非绘制代码。

12.4 双色 vs 灰阶:刷新速度的艺术

回到 12.3 那段刷新代码里最"艺术"的一行:needs_gray_flush ? MODE_GC16 : MODE_DU。先补个背景概念:电子墨水屏有双色 vs 灰度之分(说人话:双色指每个像素只能非黑即白,灰度指像素还能显示多级深浅的灰,本项目两种都支持)。它把刷新模式(说人话:刷屏的时序方式——GC16 是逐级全屏慢刷,DU 是直接快速刷新,纯黑白内容与局部小区域都用 DU)分成两档——MODE_GC16(16 级灰阶全刷,画面细腻但要数百毫秒);MODE_DU(直接更新,只保证纯黑白,速度快得多)。那么谁来决定走哪档?答案是渲染过程中埋下的一个开关:任何绘制只要使用了"非纯黑白"的颜色,needs_gray(color) 就会置位 needs_gray_flush,从而让下一次 flush_display 选择慢而全的 GC16

/* 只要画了非纯黑白的像素,就记下:这次需要 GC16 慢刷 */
void needs_gray(uint8_t color) {
  if (color != 0 && color != 255) {
    needs_gray_flush = true;
  }
}

/* draw_text 里被注释掉的一行 —— 抗锯齿文字会强制灰阶刷新 */
// needs_gray_flush = true;   // 双色字体不需要它,翻页才能快

这正是 README "Fonts" 一节强调的重点:字体脚本 scripts/generate_fonts.sh 是 EPDiy 官方脚本的改版,"lets you output the font data in only two colors which lets us update the screen considerably faster"——只输出双色字体的数据,让整屏更新明显更快。因为字形是纯黑白的,draw_text 里那行"抗锯齿就强制灰阶"的代码被注释掉了:纯文字页全程只有 0/255 两种像素,翻页就走 MODE_DU 快速通道。反过来,图片(JPEG/PNG 解码出的灰度)里几乎必然出现中间灰阶,draw_image 一旦写进中间色,这一帧就会退化成慢速 GC16。12.1 里那"先 flush 一次清场"的做法,正是在慢刷前先把上一屏的灰阶残留洗干净。

刷新模式支持的颜色速度触发条件
MODE_DU纯黑白(0 / 255)页面只有黑白像素(如纯文字页)
MODE_GC1616 级灰阶慢(数百毫秒)页面出现中间灰阶(如图片、抗锯齿)

灰阶本身也值得一说:即便走进了 GC16,写像素时仍要经过一条伽马校正曲线 gamma_curve[]——把 0..255 的线性灰阶映射到墨水屏真实的物理响应(GAMMA_VALUE = 1.0/0.8,即 1.25 次幂)。原因很简单:墨水屏的电泳响应不是线性的,直接用线性灰度画出来会"中间灰发暗"。这条曲线在构造时用 pow() 一次性算好存表,绘制时查表即可——空间换时间,也是嵌入式里常见的优化。

/* 伽马校正:把线性灰阶映射到墨水屏的物理响应 */
const float GAMMA_VALUE = 1.0f / 0.8f;
for (int v = 0; v < 256; v++) {
  gamma_curve[v] = round(255 * pow(v / 255.0, GAMMA_VALUE));
}
// draw_pixel 里:颜色先查表校正,再交给 epd_draw_pixel
达芬奇提示

把"双色"想成电子墨水屏的本色,而不是妥协:电纸书几百年来读的就是黑白纸墨。作者为速度主动砍掉抗锯齿与中间灰阶,换来的是"像翻纸质书一样快"的体验——这是把约束变成风格的典型案例。等你做自己的渲染器时,也先问一句:这页真的需要灰阶吗?

章末练习

练习 1:翻一页的完整调用链 入门

按下一个 DOWN 按键,到屏幕定格新一页,中间经历了哪些调用?结合 src/main.cppEpubReader.cpp,把从按键到 flush_display() 的完整链路写出来。

提示

按键进入消息队列 → 主循环 xQueueReceivehandleUserInteractionhandleEpub → … → 最后 renderer->flush_display()

参考答案

按键 → FreeRTOS 队列 → 主循环 xQueueReceive 收到 DOWNhandleUserInteractionhandleEpub(renderer, DOWN)reader->next()(页码 +1,必要时切章节)→ reader->render()parser->render_page(current_page, ...)(清屏、画元素)→ 返回主循环 → 画电池图标 → renderer->flush_display() → EPDiy 把帧缓冲刷上屏,屏幕定格。

练习 2:DU 与 GC16 的取舍 进阶

为什么纯文字页能走快速的 MODE_DU,而含图片的页面通常要走慢速的 MODE_GC16needs_gray() 在什么条件下置位?如果启用抗锯齿文字,翻页体验会怎样变化(结合 draw_text 里被注释掉的那行)?

提示

关键在"是否出现了非纯黑白像素";抗锯齿会在字形边缘产生中间灰阶,从而强制慢刷。

参考答案

MODE_DU 只保证纯黑白,因此只有页面里所有像素都是 0255 时才安全;needs_gray(color)color != 0 && color != 255(即中间灰阶)时置位 needs_gray_flush,使本次 flush_display 改用 MODE_GC16。文字字形是双色的,不产生中间灰阶,故纯文字页走 DU、翻页飞快;JPEG/PNG 图片解码出的灰阶会触发 GC16 慢刷。若为文字启用抗锯齿,字形边缘出现中间灰阶,每翻一页都要整屏慢刷——这正是作者注释掉那行、坚持双色字体的原因。

练习 3:计算帧缓冲 进阶

以 4.7 英寸的 ED047TC2 面板(960×540)为例:它的帧缓冲是多少字节?为什么 clear_screen() 一条 memset 就能清掉整屏?这份内存会放在 ESP32 的哪一块区域(提示:结合第 1 章的 PSRAM 讨论)?

提示

960 × 540 / 2 字节;memset 不需要关心像素含义,只要整块置同一值;PSRAM 是为了容纳这种大缓冲与解析峰值。

参考答案

帧缓冲按 4 位/像素存储,大小 = 960 × 540 / 2 = 259200 字节 ≈ 253 kB。因为是"每个字节存两个像素",memset(m_frame_buffer, 0xFF, ...) 把整块缓冲统一置值即完成清屏,无需逐像素处理。这块缓冲(连同 EPUB 解析、图片解码的临时内存)远超 ESP32 内部约 520 kB 的 SRAM 预算,因此必须放在外挂 PSRAM 里——这正是 README 把 PSRAM 列为硬性要求的直接原因。

练习 4:图片区域为什么先盖白再画 挑战

阅读 ImageBlock::render:为什么画图前要先 fill_rect(..., 255)flush_area?如果去掉 flush_area、只保留 fill_rect,会有什么后果?请结合"帧缓冲 vs 屏幕上屏"两个层次分析。

提示

帧缓冲里改了像素不等于屏幕变了,只有刷新才上屏;图片区可能残留上一页的文字,若不让它在画图前先"白掉",残影会一闪而过或残留。

参考答案

图片区域在上一页可能印着文字。绘制前 fill_rect(255) 先把这块在帧缓冲里涂白;随后 flush_areaMODE_DU 把这一小块立即局部刷上屏——若省略它,屏幕上的旧文字仍残留(帧缓冲变了、屏幕没变),直到整屏刷新才被盖掉,视觉上出现"旧字叠新图"。去掉 flush_area 后,图片区域会出现上一页文字残影,只有翻到下一页(触发整屏 flush_display)才会消失。这个例子生动说明"画进帧缓冲"与"刷到屏幕"是两步:前者廉价、后者昂贵,局部刷新正是对"昂贵"的那一步精打细算。