上一章给每个元素记好了坐标,这章把它们变成笔画。但电子墨水屏刷一次要数百毫秒,真正的艺术是取舍。
核心方法论:艺术与工程,为每个像素负责
「达芬奇画的是光影,工程师画的是像素。上一章我们把每个元素的位置都算好了,这一章的任务只是"照着坐标落笔"——听起来不难,对吧?可真正的挑战藏在落笔之后:电子墨水屏不是画布,它刷一次要几百毫秒,刷完还要定格给你看一整页。于是每一帧都在考验一个取舍——这页是只有黑白,还是要掺进灰阶?纯黑白翻页飞快,一旦出现灰阶就得整屏慢刷。别小看这一问,它就是电子墨水屏的灵魂,也是"艺术与工程"交界的那个点。」
翻页时,负责渲染(说人话:把内存里的画面数据变成屏幕上的显示,也就是把排好的内容"画出来")的 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 可以把页面"打印到串口"来调试渲染逻辑。画布可以换,而内容与布局自岿然不动。
文字渲染的关键在于:第 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_top;x 则加上 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::render | draw_text → epd_write_string |
| 图片 | ImageBlock::render | fill_rect + flush_area + draw_image |
| 图形(提示框/电池) | 各渲染器直接调用 | draw_rect / fill_rect / draw_text_box |
所有绘制最终都落在 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_string、epd_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 用它选择适配低温的波形,因为电子墨水在寒冷时离子迁移变慢,刷新质量会明显下降。这个项目里 Renderer 的 temperature 字段默认 20(摄氏度)。真机验证时,若发现冬天翻页有"重影"或"刷不净",优先怀疑温度参数而非绘制代码。
回到 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_GC16 | 16 级灰阶 | 慢(数百毫秒) | 页面出现中间灰阶(如图片、抗锯齿) |
灰阶本身也值得一说:即便走进了 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
把"双色"想成电子墨水屏的本色,而不是妥协:电纸书几百年来读的就是黑白纸墨。作者为速度主动砍掉抗锯齿与中间灰阶,换来的是"像翻纸质书一样快"的体验——这是把约束变成风格的典型案例。等你做自己的渲染器时,也先问一句:这页真的需要灰阶吗?
按下一个 DOWN 按键,到屏幕定格新一页,中间经历了哪些调用?结合 src/main.cpp 与 EpubReader.cpp,把从按键到 flush_display() 的完整链路写出来。
按键进入消息队列 → 主循环 xQueueReceive → handleUserInteraction → handleEpub → … → 最后 renderer->flush_display()。
按键 → FreeRTOS 队列 → 主循环 xQueueReceive 收到 DOWN → handleUserInteraction → handleEpub(renderer, DOWN) → reader->next()(页码 +1,必要时切章节)→ reader->render() → parser->render_page(current_page, ...)(清屏、画元素)→ 返回主循环 → 画电池图标 → renderer->flush_display() → EPDiy 把帧缓冲刷上屏,屏幕定格。
为什么纯文字页能走快速的 MODE_DU,而含图片的页面通常要走慢速的 MODE_GC16?needs_gray() 在什么条件下置位?如果启用抗锯齿文字,翻页体验会怎样变化(结合 draw_text 里被注释掉的那行)?
关键在"是否出现了非纯黑白像素";抗锯齿会在字形边缘产生中间灰阶,从而强制慢刷。
MODE_DU 只保证纯黑白,因此只有页面里所有像素都是 0 或 255 时才安全;needs_gray(color) 在 color != 0 && color != 255(即中间灰阶)时置位 needs_gray_flush,使本次 flush_display 改用 MODE_GC16。文字字形是双色的,不产生中间灰阶,故纯文字页走 DU、翻页飞快;JPEG/PNG 图片解码出的灰阶会触发 GC16 慢刷。若为文字启用抗锯齿,字形边缘出现中间灰阶,每翻一页都要整屏慢刷——这正是作者注释掉那行、坚持双色字体的原因。
以 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 列为硬性要求的直接原因。
阅读 ImageBlock::render:为什么画图前要先 fill_rect(..., 255) 再 flush_area?如果去掉 flush_area、只保留 fill_rect,会有什么后果?请结合"帧缓冲 vs 屏幕上屏"两个层次分析。
帧缓冲里改了像素不等于屏幕变了,只有刷新才上屏;图片区可能残留上一页的文字,若不让它在画图前先"白掉",残影会一闪而过或残留。
图片区域在上一页可能印着文字。绘制前 fill_rect(255) 先把这块在帧缓冲里涂白;随后 flush_area 用 MODE_DU 把这一小块立即局部刷上屏——若省略它,屏幕上的旧文字仍残留(帧缓冲变了、屏幕没变),直到整屏刷新才被盖掉,视觉上出现"旧字叠新图"。去掉 flush_area 后,图片区域会出现上一页文字残影,只有翻到下一页(触发整屏 flush_display)才会消失。这个例子生动说明"画进帧缓冲"与"刷到屏幕"是两步:前者廉价、后者昂贵,局部刷新正是对"昂贵"的那一步精打细算。