65.9K
CodeProject 正在变化。 阅读更多。
Home

GFX Forever:GFX for IoT 完全指南

starIconstarIconstarIconstarIconstarIcon

5.00/5 (50投票s)

2021年5月10日

MIT

57分钟阅读

viewsIcon

110039

downloadIcon

1215

GFX 是标准物联网绘图库的快速、功能齐全的替代品,经过优化,可减少总线 I/O。

GFX on color E-Paper

引言

我想要一个比我为各种物联网设备找到的库更快、更好的图形库。最流行的可能是 Adafruit_GFX,但它并不理想。它非常简洁,优化程度不高,并且没有完全解耦的驱动程序接口。此外,由于绑定到 Arduino 框架,它无法利用提高性能的特定于平台的特性,例如能够在 ESP32 的中断和轮询式 SPI 事务之间切换。

另一方面,GFX 不绑定于任何东西。它可以在任何平台上绘制。它基本上是标准的 C++,包括直线绘制和字体绘制算法。没有驱动程序,它只能绘制到内存中的位图,但一旦将驱动程序添加到混合中,您就可以像绘制到位图一样直接绘制到显示器上。

先前更新:列于文档末尾

更新 26:除 RA8875 外,所有 LCD/TFT/OLED 驱动程序均使用新的驱动程序框架。有关这些的信息,请参阅 此处。请注意,该文章有点过时,并且总线模板参数已更改并简化。

更新 27:包含 Wio Terminal 支持,并考虑到 Arduino 框架的差异,使 GFX 更加跨平台友好。

更新 27:简化了总线框架,并修复了 RA8875 驱动程序触摸支持中的一个 bug。

更新 28:改进了从多个驱动程序的读取速度,大幅加快了 Alpha 混合,并修复了一些编译问题。

更新 29:增加了 waveshare 5.65 英寸彩色电子纸支持。为 draw::text 添加了“无抗锯齿”选项。

构建这个大杂烩

您需要安装了 Platform IO 扩展的 Visual Studio Code。您需要一个带有连接的 ILI9341 LCD 显示器、一个 SSD1306 显示器或其他显示器的 ESP32,具体取决于演示。

我推荐 Espressif ESP-WROVER-KIT 开发板,它集成了 ILI9341 显示器和多个其他预接线的外设,以及集成的调试器和更优越的 USB 转串行桥,可实现更快的上传速度。它们可能比标准的 ESP32 开发板更难找到,但我发现它们在 JAMECO 和 Mouser 的售价约为 40 美元。如果您进行 ESP32 开发,它们绝对物有所值。尽管与 PC 相比,集成调试器非常慢,但它比连接到标准 WROVER 开发板的外部 JTAG 探针要快。

然而,你们大多数人将使用一个或多个通用的 esp32 配置。首先,决定您要使用的框架——ESP-IDF 还是 Arduino。在 VS Code 的蓝色状态栏的底部,有一个配置选择器。开始时它应设置为“默认”,但您可以通过单击“默认”来更改它。屏幕顶部将下拉出许多配置的列表。然后,您可以根据所连接的显示器选择通用的 esp32 设置,以及您想使用的框架。

要连接所有这些,请参阅 _wiring_guide.txt_,其中包含 SPI 和 I2C 显示器的接线指南。请记住,某些显示器供应商可能使用非标准名称来命名其引脚。例如,在某些显示器上,MOSI 可能标为 DINA0。您可能需要进行一些搜索才能找到您设备的具体信息。

在运行之前,您必须在 Platform IO 侧边栏 - 任务下上传文件系统映像。

注意:Platform IO IDE 有时有点古怪。第一次打开项目时,您可能需要转到左侧的 Platform IO 图标——它看起来像一个外星人。单击它以打开侧边栏,然后在 _快速访问|杂项_ 下查找 _Platform IO Core CLI_。单击它,然后在出现提示时键入 pio run 以强制其下载必要的组件并进行构建。您不应该需要再次执行此操作,除非在尝试构建时再次遇到错误。此外,不知何故,每当您切换配置时,您都必须刷新(“项目任务”旁边的圆形箭头图标)才能使其生效。

尽管 GFX 的 GFX 功能对于每个显示器来说几乎相同,但演示项目并非完全相同。原因是 I2C 设备没有异步操作,而且有些显示器不够大,无法显示 jpg,或者它们是单色的,例如 SSD1306,所以 JPG 会看起来很糟糕。

目前支持以下配置。选择您想在构建前使用的配置:

  • esp-idf-esp-wrover-kit
  • esp-idf-lilygo-ttgo
  • esp-idf-ST7735
  • esp-idf-ILI9341
  • esp-idf-ST7789
  • esp-idf-SSD1306
  • esp-idf-SSD1351
  • esp-idf-MAX7219*
  • esp-idf-GDEH0154Z90
  • arduino-esp-wrover-kit
  • arduino-lilygo-ttgo
  • arduino-lilygo-t5_v22
  • arduino-ILI9341
  • arduino-ILI9341-P8
  • arduino-DEPG0290B
  • arduino-MAX7219*
  • arduino-GDEH0154Z90
  • arduino-ST7789
  • arduino-SSD1306
  • arduino-SSD1306-i2c
  • arduino-SSD1351
  • arduino-ST7735
  • arduino-RA8875
  • ardiuino-waveshare5in65
  • arduino-TFT_eSPI
  • arduino-wio-terminal
  • windows-DirectX

MAX7219 的 CS 引脚是 15,而不是 5。驱动程序是实验性的,多行段不工作,但单行中的多个段可以工作。

使用 TFT_eSPI 构建

Bodmer 的 TFT_eSPI 可能是 Arduino 框架中最快的 TFT 驱动程序。它确实是一个很棒的产品,但它的工作方式与 GFX 不同,并且具有不同的功能。使用 gfx_tft_espi 驱动程序,您可以将 GFX 与 TFT_eSPI 结合使用,享受更高的帧率和更多支持的显示器,同时利用 GFX 的功能,如 True Type 和灵活的像素格式。

出于几个原因,我不会在此分发版中提供 Bodmer 的库,并且不建议通过 lib_deps / 使用 platform IO 的自动安装程序获取它。从 Bodmer 的 Github 存储库此处下载,并将其放在 gfx_demo 项目的 libs 文件夹下。切换到 arduino-TFT_eSPI 配置。只要此库在该文件夹中,其他项目就不会构建。不幸的是,我还没有找到一种方法可以在不修改 TFT_eSPI 本身的情况下解决此问题,这基本上是不可行的。

这是过程

  • 下载库并解压。
  • 将其放在 /lib 下(TFT_eSPI-master 文件夹应与 gfx 文件夹并列)
  • 将您的配置切换到 arduino-TFT_eSPI
  • 通过在他库的文件夹下的 User_Setup.h 中设置您的显示器和引脚来配置 TFT_eSPI 库。

使用 Windows 和 DirectX 构建

首先,转到 PlatformIO/快速访问/杂项/PlatformIO Core CLI,打开一个新的控制台并输入

pio platform install "windows_x86"

转到您的项目,然后打开 _/src/windows/directx_demo.hpp_。在那里,更改 #define FONT_PATH "Maziro.ttf",以便路径与该字体文件的完整路径匹配,该路径将位于 _/fonts_ 文件夹下。

接下来,这有点棘手,因为 Microsoft 的 C++ 编译器和 GCC 之间存在不一致。您必须修补一个系统头文件,否则您的程序将崩溃。此修补程序不会损害其他程序,事实上,如果使用 GCC 编译,其他程序仍将在修补后的头文件中崩溃。这是因为您必须定义 WIDL_EXPLICIT_AGGREGATE_RETURNS 才能使修补后的代码生效。

现在您需要构建 Windows 配置,以确保 PlatformIO 拥有所有必要的文件。

接下来,您需要找到 PlatformIO 的系统头文件 _d2d1.h_ 的副本。您可以通过打开 _windows/drivers/dxd2d.hpp_ 并找到 #include <d2d1.h>,然后右键单击文件名并选择“转到定义”来找到它。在那里,右键单击它的选项卡以复制完整路径。

接下来,使用 Windows 搜索栏搜索记事本,右键单击结果,然后单击“以管理员身份运行”。

打开后,转到打开文件,并将路径粘贴到对话框中,然后按 Enter。

转到 VS Code,然后复制 _d2d1_patched_minigw.h_ 的内容,并粘贴到您的记事本实例中。最后,保存。

现在,请清理您的项目。这必须完成。

现在您可以构建一个工作应用程序。请记住,Windows 的流程与 IoT 应用程序的工作方式截然不同。您必须在 WM_PAINT 事件上渲染每一帧,因此演示的结构与其他演示大不相同,它采用状态机来为行演示创建一个协程。

要运行应用程序,请再次打开一个控制台。然后您需要键入以下命令:

.pio\build\windows-DirectX\program.exe

驱动程序不是很快,因为 DirectX 和 GFX 之间存在“极性不匹配”。DirectX 的工作方式与 GFX 大不相同,而弥合差距效率不高。此驱动程序用于原型屏幕,并且对于它们来说是有效的,因为它缩短了构建时间并消除了上传时间。

电子纸显示器支持

黑白电子纸显示器驱动程序可以通过抖动虚拟化扩展的位深度来模拟灰度。驱动程序的最后一个模板参数指示位深度,默认为 1,禁用抖动。

一些彩色电子纸显示器会进行抖动,或者它们会匹配调色板中最接近的颜色。如果您需要更好的抖动性能,您可以在目标电子纸显示器调色板的绘图程序中预先抖动您的 JPEG,然后禁用虚拟化。最后一个模板参数指示您希望虚拟化的彩色像素的像素类型。

请记住,虚拟化位深度越高,驱动程序使用的内存就越多。

这些驱动程序中的大多数都具有额外的函数,例如 sleep(),它们没有 draw:: 访问器。您必须直接与驱动程序通信才能访问这些功能。

解耦总线和 8 位并行支持

较新的 Arduino 驱动程序支持新的驱动程序框架,因此可以操作 I2C、8 位并行或 SPI。SPI 还支持 DMA,性能优于旧的基于 SPI 的框架,并且可以读取支持它的设备。

使用它需要一些额外的步骤,因为驱动程序独立于总线。

首先,包含 I/O 头文件

#include "drivers/common/tft_io.hpp"

您必须初始化您的总线。请记住,并行总线的数据和 DR 引脚存在限制。为了安全起见,它们应该在 GPIO 范围 0-31 内才能工作。我已经包含了一些代码来使它们正常工作,但我尚未对其进行测试。

初始化 SPI 总线(在 ESP32 上)

using bus_type = tft_spi_ex<VSPI,PIN_NUM_CS,PIN_NUM_MOSI,PIN_NUM_MISO,PIN_NUM_CLK,SPI_MODE0,
true,320*240*2+8>;

以上在 VSPI 上初始化了总线并使用了 DMA。

初始化并行总线类似,除了您只需要引脚编号。

一旦实例化了总线类型,您就可以将其传递给驱动程序模板

using lcd_type = ili9341<PIN_NUM_DC,PIN_NUM_RST,PIN_NUM_BCKL,bus_type,3>;

有关此信息,请参阅 此处

概念

下面是一个高级摘要。有关更详细的信息,我一直在制作一个系列,该系列 从链接开始

绘图源和目标

GFX 引入了绘图源和目标的概念。这些是松散或模糊定义的类型,它们公开一组成员,允许 GFX 绑定到它们以执行绘图操作。它们公开的成员集取决于它们的功能,GFX 会根据目标为给定操作支持的最有效方式进行调整。GFX 中的绘图函数接受目标,而某些操作(如复制位图像素数据或读取像素信息)则需要源。

代码大小与您拥有的不同类型的源和目标的数量以及您在它们之间进行的绘图或复制的种类有关。您的源和目标越多,代码就越大。

绘图源和目标包括内存中的位图和显示驱动程序,但您也可以创建自己的。

位图

位图是一种多用途的绘图源/目标。它们基本上允许您将绘制的数据存储在内存中,然后将其绘制到其他位置。位图不自行存储内存。这是因为并非所有内存都均等。例如,在某些平台上,为了能够将位图发送到驱动程序,位图数据必须存储在 DMA 功能的 RAM 中。另一个原因是您可以回收缓冲区。当您完成一个位图后,您可以将内存重用于另一个位图(只要它足够大),而无需取消分配或重新分配。缺点是代码复杂度略有增加,您必须确定位图所需的大小,然后自己分配内存,并可能稍后自己释放它。

驱动程序

驱动程序通常是功能有限的绘图目标,但可能具有性能功能,GFX 将在可用时使用它们。您可以使用 caps 成员检查绘图源和目标可用的功能,以便您可以调用适合您任务和设备的正确方法。如果您使用 draw 类,它会为您完成所有工作,因此您不必担心。驱动程序在一次操作中可以接受的位图大小可能存在某些限制,或者性能特征因设备而异。在我看来,我已经尽力使它们在不同源/目标之间保持一致,但有些设备(如电子纸显示器)差异很大,在使用它们时必须小心,以便以最有效的方式使用它们。

自定义

您可以通过编写具有适当成员的类来实现自己的绘图源和目标。我们将在最后进行介绍。

像素类型

GFX 中的像素可以采用您能想象到的任何形式,直到您机器的最大字大小。它们可以拥有多达每位一个通道,并且将采用您指定的任何二进制足迹。如果您想要一个 3 位 RGB 像素,您可以轻松创建一个,然后在此格式中创建位图 - 其中每 9 位有 3 个像素。您永远不必担心此库是否支持您的显示驱动程序或文件格式的像素和颜色模型。使用 GFX,您可以定义像素的二进制足迹和通道,然后通过告诉它如何与 RGB 相互转换来扩展 GFX 以支持除 4 种之外的其他颜色模型*。

* 索引像素格式已得到考虑,但在使用它们时存在某些限制,需要小心,因为它们需要关联的调色板才能解析为颜色。

当您声明一个像素格式时,它就成为使用它的任何事物的类型签名的一部分。例如,使用 24 位像素格式的位图与使用 16 位像素格式的位图是不同的类型。

因此,您拥有的像素格式越多,代码大小就会越大。

Alpha 混合

如果您创建带有 alpha 通道的像素,支持的设备将尊重它。并非所有设备都支持启用此功能所需的功能。它也是性能杀手,如果没有硬件支持,无法使其更快,而目前没有任何支持的设备提供硬件支持。通常,由于性能问题以及许多驱动程序目前不充当绘图源,因此无法进行 Alpha 混合,并且通常最好在位图上进行 Alpha 混合,然后将位图绘制到驱动程序中。rgba_pixel<> 模板将创建一个带有 alpha 通道的 RGB 像素。

索引颜色/调色板支持

某些绘图目标使用索引颜色方案,其中它们可能具有例如 16 种活动颜色,从 262,144 种可能的颜色中选择。或者它们可能具有固定的 8 种活动颜色,仅此而已。活动颜色是当时唯一可以显示的颜色。这在旧的帧缓冲区大小有限的系统上很常见。某些物联网显示硬件上也可能如此,特别是彩色电子纸显示器。据我所知,电子纸显示器的颜色范围从 2 色(单色)到 7 色。

使用索引像素作为其 pixel_type 的绘图目标——即,像素具有 channel_nameindex 的通道的设备——需要公开一个 palette_type 类型别名来指示其正在使用的调色板的类型,以及一个 palette_type* palette() const 方法来返回指向当前调色板的指针。

当您绘制到具有索引像素的目标时,会尽最大努力将请求的颜色与调色板中的活动颜色之一进行匹配。它会找到最接近的颜色。这并非免费。它非常占用 CPU,所以买家要小心,尤其是在将 JPEG 或其他内容加载到索引目标时。它将必须为每个像素运行最近邻匹配,并且每个像素扫描一次调色板!

您必须小心处理索引颜色。它们不能孤立使用,因为没有调色板,您就没有足够的信息从中获取颜色。绘图目标可以有调色板,因此索引像素和匹配的绘图目标的组合可以产生有效的颜色。因此,您不能将 color<> 模板与索引颜色一起使用,例如,因为没有调色板,无法知道索引值对应于 old_lace,甚至 white。当您尝试在无法使用索引像素的地方使用它们时,您应该会收到编译错误,但最坏的情况下,在尝试绘制时会收到运行时错误。尝试绘制时,通常不必担心这个问题。

如果您必须自己将索引像素转换为其他类型的像素,可以使用 convert_palette_from<>()convert_palette_to<>()convert_palette<>()。它们将与索引颜色相互转换,并可选择在此过程中进行 Alpha 混合。它们接受绘图目标以访问调色板信息。

绘图元素

绘图元素只是可以绘制的对象,例如线条和圆。

图元

绘图图元包括直线、点、矩形、弧形、椭圆和多边形,除前两者外,都提供填充和非填充版本。大多数需要边界矩形来定义它们的范围,对于某些(如弧形和直线),矩形的朝向会改变元素的绘制位置或方式。

绘图源

再次说明,绘图源是位图或支持读取操作的显示驱动程序。这些可以绘制到绘图目标,同样,就像支持写入操作的其他位图或显示驱动程序一样。在绘图操作中使用越多的不同类型的源和目标组合,代码大小就越大。绘制这些时,目标矩形的朝向指示是沿水平还是垂直轴翻转绘制。绘图也可以调整大小、裁剪或像素格式转换。

字体

GFX 支持两种类型的字体。它支持一种快速的光栅字体以及 True Type 或 Open Type 字体,具体取决于您的需求。如果您需要快速粗糙的字体,并且重点是快速,请使用 font 类。对于美观、可缩放且可能具有抗锯齿效果的字体,但会牺牲性能,请使用 open_font 类。

每种字体在行为和设计上略有不同,因为它们的功能和性能考虑因素不同。例如,光栅字体始终分配在 RAM 或 PROGMEM 空间中。这是因为它们很小,并且可以以最大速度运行。另一方面,TrueType 字体较大,结构更复杂,因此 GFX 会根据需要直接从文件流式传输它们,用最少的 RAM 使用量来换取速度。与光栅字体不同,TrueType 字体基本上不会加载到内存中,只会根据需要进行临时内存分配以渲染文本。

fontopen_font 都可以从可读、可寻址的流(如 file_stream)加载。这样,font 会比嵌入式时稍微快一些,而 open_font 会慢得多。光栅字体是旧的 Windows 3.1 FON 文件,而 TrueType 字体文件是平台无关的 TTF 和 OTF 文件。

或者,您可以使用 _fontgen_ 工具从字体文件创建 C++ 头文件。然后可以包含此头文件以将字体数据直接嵌入到您的二进制文件中。这是一个静态资源,而不是加载到堆中。当您可以加载字体时,这是推荐的方法,特别是对于 open_font

绘制字体时,支持基本的终端控制字符,如制表符、回车符和换行符。光栅字体可以绘制成有背景或无背景,尽管几乎总是绘制有背景要快得多,尤其是在绘制到显示器时。

True Type 布局注意事项

True Type 字体通常必须从其原生大小缩小后再显示。您可以使用 scale() 方法,传入所需的字体高度(以像素为单位)。

请注意,True Type 的大小和位置有点近似,因为它们并不总是符合您的预期。其中一部分是数字排版的性质,另一部分是非商业字体文件通常具有错误的字体度量。通常需要一些反复试验才能使其像素完美。

另请注意,与光栅字体不同,True Type 字体字形不受边界框的限制。它们可能在指定的绘制区域外部有字母的悬垂部分,这可能导致您目标区域中字母的左边缘和上边缘被裁剪。幸运的是,您可以使用 offset 参数绘制文本,以在绘制区域内偏移文本,并且/或者调整文本的精确位置。

除了加载和提供字体基本信息外,fontopen_font 类还允许您测量特定文本区域在给定字体中所需的空间。

图像

图像包括 JPEG 等,目前这是此库支持的唯一格式,尽管 PNG 将很快添加。

图像不是绘图元素,因为一次性将图像加载到内存中或由于压缩或渐进式存储数据等原因而获得对其中像素数据的随机访问是不切实际的。

要处理图像,您可以使用 draw 类,该类会将整个或部分图像绘制到目标,或者您可以处理一个回调,该回调一次报告图像的一部分,以及一个指示该部分在图像中的位置。例如,对于 JPEG,每个 8x8 区域从左到右、从上到下都会返回一个小的位图(通常约 8x8)。每次收到一部分时,您可以将其绘制到显示器或其他目标,或者您可以对其进行后处理等。

目前,与字体不同,没有工具可以创建将图像直接嵌入到您的二进制文件中的头文件。这可能将在未来的版本中添加。

性能

对于大多数情况,GFX 会尽最大努力减少调用驱动程序的次数(批次写入除外),即使这意味着让 CPU 做更多的工作。例如,GFX 不会将对角线绘制为一系列点,而是将直线绘制为一系列水平或垂直线段。GFX 不会逐点渲染字体,而是尽可能进行批处理,或者使用游程长度将字体绘制为一系列水平线而不是单个点。用 CPU 换取更少的总线流量是值得的,因为通常前者比后者有更多的剩余,而且也不是说你有多少。GFX 相对高效,避免了像虚函数调用这样的东西,但大部分收益是通过减少总线级别的通信来实现的。

也就是说,有一些方法可以通过使用 GFX 的功能来显著提高性能,这些功能正是为此而设计的。

批处理

提高性能的一种方法是通过批处理来减少总线流量。通常,要执行绘图操作,设备必须事先设置绘图的目标矩形,包括设置单个像素时。在 ILI9341 显示器上,这会导致 6 次 SPI 事务才能写入一个像素,因为需要 DC 线控制,这会将每个命令分成两次事务。

一种大幅减少开销的方法是设置此地址窗口,然后通过从左到右、从上到下写入像素来填充它,而无需指定任何坐标。这被称为批处理,它可以通过数量级的方式减少总线流量并提高性能。GFX 将在可能的情况下为您使用它,前提是这是绘图目标的一项可用功能。

主要缺点是使用它的机会有限。它非常适合绘制填充矩形,或在无法使用位块传输和 DMA 传输时绘制位图,但您必须愿意用像素填充整个矩形才能使用它。如果您绘制的字体带有透明背景或 45 度对角线,GFX 将无法进行批处理。每当必须绘制整个像素矩形,并且无法直接传输位图数据(因为源或目标不支持,或者因为需要进行位深度转换或调整大小等操作)时,就可以进行批处理。批处理是加速这些类型操作的好方法。除非您直接与驱动程序通信,否则您无法直接使用它,因为在驱动程序级别,不正确使用它可能会在显示内容方面导致问题。当您使用 draw 时,GFX 将尽可能地使用它。

双缓冲与暂停/恢复

某些设备支持双缓冲。当缓冲区位于本地 RAM 中时(如 SSD1306 驱动程序的情况),这可以减少总线流量,但即使它存储在显示设备上,使用暂停和恢复也可以使您的绘图不那么闪烁。发生的情况是,一旦暂停,所有绘图操作都会被存储而不是发送到设备。恢复后,绘制的内容会一次性更新。在某些情况下,这可能会产生比平时更多的总线流量,因为恢复通常必须将一个包围整个修改部分的矩形发送到设备。因此,这更多是为了平滑绘制而不是原始吞吐量。GFX 在绘制时会自动使用它,但您也可以自己使用它来扩展缓冲绘图的范围,因为 GFX 不会知道您打算在更新显示器之前绘制 10 条线。但是,即使您不这样做,它也会逐行缓冲,前提是目标支持。

异步操作

如果使用得当,异步操作是提高图形密集型应用程序吞吐量的强大方法。使用它的主要缺点是,与同步对应项相比,异步操作通常会产生处理开销,再加上您必须非常小心地使用它,并且在传输大量数据时才能获得收益,这意味着大量内存使用。

然而,当您需要一次传输大量数据时,使用异步操作可以带来巨大的好处,允许您在后台启动一次大型传输,然后几乎立即开始绘制下一帧,远在传输完成之前。

通常,为了实现这一点,您将创建两个较大的位图(例如 320x16),然后您所做的是绘制到一个位图,同时异步发送另一个位图,一旦发送完成,您就交换它们,这样您就可以绘制到后者,同时发送您刚刚绘制的第一个位图。

异步绘制位图是您唯一能看到吞吐量改进的时候。存在其他异步 API 调用也是因为通常为了从异步切换到同步操作,目标队列中的所有挂起的异步操作都必须完成,所以基本上在您将位图排队绘制之后,您可以继续排队异步直线绘制等,以避免等待挂起的操作完成。然而,当您使用上述交换位图的方法进行异步处理时,这些其他异步方法将不再需要,因为绘制到位图始终是同步的,与总线流量或排队异步总线事务无关。同步绘制到位图不会影响绘图目标的异步队列。每个异步队列都特定于相关的绘图源或目标。

按框架划分的性能差异

ESP-IDF

ESP-IDF 能够通过 SPI 进行异步 DMA 传输,但目前总体 SPI 吞吐量低于 Arduino 框架。我正在调查原因。我有一些相关问题阻碍了我支持 ESP-IDF 下的某些设备,例如 RA8875。ESP-IDF 驱动程序目前不支持 8 位并行。这将在未来添加。

Arduino 框架

Arduino 框架的 SPI 互操作性是紧密计时且快速的,但本身不支持异步 DMA 传输,但一些 ESP32 驱动程序会支持——并且似乎没有设施可以在 SPI 读取和写入操作期间返回错误。因此,由于 SPI 通信 API 特性和行为的差异,Arduino 版本驱动程序出现接线问题的可能性较小,但我不太确定,因为我没有尝试创建这种情况来进行测试。Arduino 框架比 ESP-IDF 更可能支持某个设备。Arduino 驱动程序包括 8 位并行支持。

使用 GFX API

包含 _gfx.hpp_ (C++17) 或 _gfx_cpp14.hpp_ (C++14) 以访问 GFX。同时包含两者将选择先包含的那个,所以不要同时包含两者。根据您要定位的 C++ 标准选择您需要的。

对于 Platform IO 下的 ESP-IDF 工具链,我只能将其定位到 C++14。它们在 Windows 下使用的 gcc 编译器不够新,不支持更新的标准。C++17 版本在预定义颜色函数的效率方面略高,并且由于更多的 constexpr 解析,效率可能更高。不过,在使用它们时并没有什么区别。

使用 gfx 命名空间来访问 GFX。

Headers

不必显式包含其中任何一个,尽管如果您正在编写驱动程序代码,您可以包含一个子集以稍微减少编译时间。

  • gfx_core.hpp - 访问基本通用类型,如 gfx_result
  • gfx_pixel.hpp - 访问 pixel<> 类型模板。
  • gfx_positioning.hpp - 访问点、大小、矩形和路径类型及模板。
  • gfx_bitmap.hpp - 访问 bitmap<> 类型模板。
  • gfx_drawing.hpp - 访问 GFX 的主要功能 draw
  • gfx_color.hpp - 通过 color<> 模板访问预定义颜色,适用于 **C++17** 或更高版本编译器。
  • gfx_color_cpp14.hpp - 通过 color<> 模板访问预定义颜色,适用于 **C++14** 编译器。
  • gfx_image.hpp - 访问 jpeg_image,用于加载 JPEG 图像。
  • gfx_palette.hpp - 访问调色板支持类型。

 

GFX 使用 _泛型编程_ 进行设计,这在针对小型 MCU 的代码中并不常见,但在这里它提供了许多好处,而且由于其协调方式,缺点很少。

首先,没有任何东西是二进制耦合的。您不继承任何东西。如果您告诉 GFX 您支持某个方法,您只需实现该方法即可。如果您不这样做,当 GFX 尝试使用它时,将会出现编译错误。

这样做的优点是,方法可以被内联、模板化,并以二进制接口无法实现的方式进行修改。它们也不需要间接调用。缺点是,如果某个方法从未被使用,编译器将除了解析之外不会检查其中的代码,但这种情况发生的唯一方式是您实现了一个方法,然后您没有告诉 GFX 您实现了它,例如异步操作。

在典型使用 GFX 时,您将首先声明您的类型。由于一切基本上都是模板,因此在使用它们之前,您需要实例化它们的具体类型。using 关键字非常适合此目的,我推荐它而不是 typedef,因为它可模板化,并且至少在我看来,它更具可读性。

您通常需要一个用于驱动程序,一个用于您希望声明的任何类型的位图(您需要不同类型的位图,具有不同的颜色模型或位深度,例如 RGB 与单色)。完成此操作后,您将为每个像素类型需要一个颜色模板。至少,您会想要一个与您的显示设备像素类型匹配的,例如使用 using lcd_color = gfx::color<typename lcd_type::pixel_type>;,这将允许您引用 lcd_color::antique_white 等内容。

完成这些之后,几乎所有其他事情都使用 gfx:draw 类来处理。尽管类上的每个函数声明了一个或多个模板参数,但相应的参数是从传递给函数的参数中推断出来的,因此您永远不需要显式使用 <>draw。使用 draw,您可以绘制文本、位图、线条和简单形状。

除此之外,您还可以声明字体和位图。它们在持有期间会占用资源,并且可以作为参数传递给 draw::text<>()draw::bitmap<>()

图像不直接占用资源,除了加载期间的一些簿记。它们不会加载到内存中并保留,而是调用者会被回调,提供包含图像部分的位图以及它在图像中的位置。这种渐进式加载是必要的,因为实际上,GFX 设计的大多数机器都没有内存一次性加载真实世界图像。

一些基础知识

让我们深入研究一些代码。以下代码在屏幕的四个边缘周围绘制经典的特效,使用四种不同的颜色,并在屏幕中央显示“ESP32 GFX Demo”。

draw::filled_rectangle(lcd,(srect16)lcd.bounds(),lcd_color::white);
const font& f = Bm437_ATI_9x16_FON;
const char* text = "ESP32 GFX Demo";
srect16 text_rect = f.measure_text((ssize16)lcd.dimensions(),
                        text).bounds();

draw::text(lcd,text_rect.center((srect16)lcd.bounds()),text,f,lcd_color::dark_blue);

for(int i = 1;i<100;++i) {
    // calculate our extents
    srect16 r(i*(lcd_type::width/100.0),
            i*(lcd_type::height/100.0),
            lcd_type::width-i*(lcd_type::width/100.0)-1,
            lcd_type::height-i*(lcd_type::height/100.0)-1);
    // draw the four lines
    draw::line(lcd,srect16(0,r.y1,r.x1,lcd_type::height-1),lcd_color::light_blue);
    draw::line(lcd,srect16(r.x2,0,lcd_type::width-1,r.y2),lcd_color::hot_pink);
    draw::line(lcd,srect16(0,r.y2,r.x1,0),lcd_color::pale_green);
    draw::line(lcd,srect16(lcd_type::width-1,r.y1,r.x2,lcd_type::height-1),lcd_color::yellow);
    // the ESP32 wdt will get tickled
    // unless we do this:
    vTaskDelay(1);
}

首先,通过在整个屏幕上绘制一个白色矩形,屏幕被填充为白色。请注意,绘图源和目标将它们的边界报告为无符号矩形,但 draw 通常接受有符号矩形。这没什么大不了的,只需显式强制转换即可解决,并且我们在上面需要时会这样做。

之后,我们声明一个对我们包含在头文件中的 font 的引用。头文件是通过 GFX 附带的 _fontgen_ 工具从旧的 Windows 3.1 _*.FON_ 文件生成的。GFX 也可以将它们从流(如文件)加载到内存中,而不是将它们嵌入到二进制文件中作为头文件。每种方法都有优缺点。头文件不够灵活,但允许您将字体存储在程序内存中,而不是将其保留在堆上。

现在我们声明一个要显示的字符串字面量,这没什么特别的,然后是一个更有趣的东西。我们正在测量即将显示的文本,以便能够将其居中。请记住,测量文本需要一个初始的 ssize16,该值指示字体可用的总区域,这允许文本换行等。基本上,测量文本会接收此大小并返回一个缩小到足以容纳给定字体文本所需的最小大小。然后我们获取此大小的 bounds() 以获得边界矩形。请注意,在 draw::text<>() 时,我们调用此矩形的 center()

之后,我们总共绘制了 396 条线,围绕显示器的边缘,例如在屏幕周围创建摩尔纹效果。每组线都固定在其自己的角落,并以其自己的颜色绘制。

将 GFX 的直线绘制性能与其他库进行比较。您会惊喜不已。线条离 45 度(或任何完美的对角线)越远,它绘制的速度就越快——至少在大多数设备上如此——水平线和垂直线是最快的。

双缓冲、暂停和恢复

让我们再试一次——或者至少是类似的东西——这次是在支持的目标上使用双缓冲,例如 SSD1306 显示器。请注意,suspend<>()resume<>() 可以被调用,无论绘图目标如何,但它们会在不支持双缓冲的目标上报告 gfx::gfx_result::not_supported。您不必太在意,因为绘图仍然可以工作,只是未缓冲。无论如何,代码如下:

draw::filled_rectangle(lcd,(srect16)lcd.bounds(),lcd_color::black);
const font& f = Bm437_Acer_VGA_8x8_FON;
const char* text = "ESP32 GFX";
srect16 text_rect = srect16(spoint16(0,0),
                        f.measure_text((ssize16)lcd.dimensions(),
                        text));
                        
draw::text(lcd,text_rect.center((srect16)lcd.bounds()),text,f,lcd_color::white);

for(int i = 1;i<100;i+=10) {
    draw::suspend(lcd);

    // calculate our extents
    srect16 r(i*(lcd_type::width/100.0),
            i*(lcd_type::height/100.0),
            lcd_type::width-i*(lcd_type::width/100.0)-1,
            lcd_type::height-i*(lcd_type::height/100.0)-1);

    draw::line(lcd,srect16(0,r.y1,r.x1,lcd_type::height-1),lcd_color::white);
    draw::line(lcd,srect16(r.x2,0,lcd_type::width-1,r.y2),lcd_color::white);
    draw::line(lcd,srect16(0,r.y2,r.x1,0),lcd_color::white);
    draw::line(lcd,srect16(lcd_type::width-1,r.y1,r.x2,lcd_type::height-1),lcd_color::white);

    draw::resume(lcd);
    vTaskDelay(1);
}

除了少数细微差别外,主要是因为我们正在处理一个更小的单色显示器,这与之前的代码基本相同,但有一个主要区别——存在 suspend<>()resume<>() 调用。调用 suspend 后,进一步的绘图操作将不会显示,直到调用 resume。调用应该平衡,因此要恢复显示器,您必须调用 resume 的次数与调用 suspend 的次数相同。这允许您拥有可以暂停和恢复自己的绘图的子例程,而不会弄乱您的代码。事实上,GFX 在支持的设备上绘制单个元素时会使用暂停和恢复。您拥有它的主要原因是您可以将范围扩展到几个绘图操作。

关于暂停/恢复和电子墨水/电子纸显示器的说明

这类显示器的刷新率非常慢。但是,GFX 在如何使用它们方面并不区分电子纸显示器和传统的 TFT/LCD/OLED 显示器。因此,为了达到合理的性能,一次性暂停和恢复整个帧很重要。对于这些显示器来说,动画是不可能的。其中一些显示器支持部分更新,理论上可以提高刷新率。然而,这些显示器的文档并不完善,我尚未成功使其工作。

让我们来画多边形

自从添加了多边形支持以来,我想一个例子会很有帮助。以下是它的实际应用:

// draw a polygon (a triangle in this case)
// find the origin:
const spoint16 porg = srect16(0,0,31,31)
                        .center_horizontal((srect16)lcd.bounds())
                            .offset(0,
                                lcd.dimensions().height-32)
                                    .top_left();
// draw a 32x32 triangle by creating a path
spoint16 path_points[] = {spoint16(0,31),spoint16(15,0),spoint16(31,31)};
spath16 path(3,path_points);
// offset it so it starts at the origin
path.offset_inplace(porg.x,porg.y);
// draw it
draw::filled_polygon(lcd,path,lcd_color::coral);

这将在屏幕底部水平居中绘制一个小的三角形。最困难的部分是找到原点,但即使这样也不难,如果您逐个分解 porg 调用。

任何情况下的像素

您可以使用 pixel<> 模板定义像素,该模板接受一个或多个 channel_traits<> 作为参数,它们本身接受名称、位深度以及可选的最小值、最大值、默认值和比例。通道名称是预定义的,通道名称的组合构成了已知颜色模型。已知颜色模型是基本上可以与 RGB 颜色模型相互转换的模型。目前它们包括 RGB、Y'UV、YbCbCr 和灰度。声明像素以创建不同格式的位图,或声明彩色像素以与您的特定显示驱动程序的原生格式一起使用。在极少数情况下,您需要手动定义一个,您可以这样做:

// declare a 16-bit RGB pixel
using rgb565 = pixel<channel_traits<channel_name::R,5>,
                    channel_traits<channel_name::G,6>,
                    channel_traits<channel_name::B,5>>;

这声明了一个具有 3 个通道的像素,每个通道都是 uint8_tR:5G:6B:5。请注意,冒号后面是它拥有的有效位数。uint8_t 类型仅用于表示代码中的每个像素通道值。在二进制空间中,例如在内存中的位图中,像素占用 16 位,而不是 24 位。这定义了您在物联网平台的彩色显示适配器上通常找到的标准 16 位像素。RGB 是已知颜色模型之一,因此有一种简写方式可以声明任何位深度的 RGB 像素类型:

using rgb565 = rgb_pixel<16>; // declare a 16-bit RGB pixel

这将平均分配位数,剩余的位分配给绿色通道。它是上面给出的长格式声明的简写,并且解析为该格式。

像素主要用于表示颜色,并定义位图或帧缓冲区的二进制布局。位图是按其像素类型输入的模板。因此,不同像素类型的位图本身就是不同的类型。

像素具有丰富的 API,允许您按名称或索引读取和写入单个通道,并获取有关像素的令人眼花缭乱的元数据,您应该永远不需要这些元数据。

大多数时候,您只需要从绘图源读取像素值,或者从标准颜色值中获取它们。但是,有时您可能需要自己设置像素颜色。

每个像素由您声明的通道组成,并且可以通过“名称”(channel_name 枚举)或索引访问通道。可以使用 channel<>() 访问器(用于整数值)和 channelr<>() 访问器(用于缩放到零到一之间的实数/浮点值)来检索或设置值。很多时候,您需要基于其他编译时常量以编程方式设置或获取通道,并且编译器会抱怨,因为它无法验证该通道是否存在。为了避免这种情况,您可以使用 channel_unchecked<>(),它在没有编译时验证的情况下访问通道。如果通道不存在,设置和获取将不起作用。如果您需要转换通道的实际值和整数值之间的转换,您可以使用通道的 ::scale::scaler 值。

// declare a 24-bit rgb pixel
rgb_pixel<24> rgb888;

// set channel by index
rgb888.channel<0>(255); // max
// set channel by name
rgb888.channel<channel_name::G>(127);
// set channel real value
rgb888.channelr<2>(1.0); // max

// get red as an int type
uint8_t r = rgb888.channel<channel_name::R>();
// get green as a real type
float g = rgb888.channelr<channel_name::G>();
// get blue as an int type
uint8_t b = rgb888.channel<channel_name::B>();

// get the pixel value in big endian form
uint32_t v = rgb888.value();

此外,当您包含主 gfx 头文件时,还提供了一系列标准颜色定义。

这些可以通过 color<> 模板访问,该模板提供了一个伪枚举,包含了几十种颜色,以您指定的任何像素格式——作为模板参数。即使您将 hot_pink 检索为单色或灰度像素,它也会为您进行转换,或者我应该说编译器会进行转换(至少对于 C++17 来说,我还没有检查过 14 的汇编输出)。

使用 Alpha 通道

具有 channel_name::A 的像素被称为具有 alpha 通道。在这种情况下,颜色可以是半透明的。内部颜色将与背景颜色混合绘制,前提是目标能够读取,或者本地支持 Alpha 混合(如其 pixel_format 具有 alpha 通道)。不支持此功能的目标将不尊重 alpha 通道,也不会进行混合。任何 draw 方法都可以接受带有 alpha 通道的像素颜色。这是一种强大的颜色混合方式,但权衡是大多数情况下性能会显著下降,因为需要逐像素绘制才能应用混合。rgba_pixel<> 模板将创建一个带有 alpha 通道的 RGB 像素。

以下是在实际中使用它的一个例子:

using bmpa_type = rgba_pixel<32>;
using bmpa_color = color<bmpa_type>;

// do some alpha blended rectangles
bmpa_type col = bmpa_color::yellow;
col.channelr<channel_name::A>(.5);
col = bmpa_color::red;
col.channelr<channel_name::A>(.5);
draw::filled_rectangle(bmp,
                    srect16(
                        spoint16(0,0),
                        ssize16(
                            bmp.dimensions().width,
                            bmp.dimensions().height/4)),
                    col);
col = bmpa_color::blue;
col.channelr<channel_name::A>(.5);
draw::filled_rectangle(bmp,
                    srect16(
                        spoint16(0,0),
                        ssize16(
                            bmp.dimensions().width/4,
                            bmp.dimensions().height)),
                    col);
col = bmpa_color::green;
col.channelr<channel_name::A>(.5);
draw::filled_rectangle(bmp,
                    srect16(
                        spoint16(0,
                            bmp.dimensions().height-
                                bmp.dimensions().height/4),
                        ssize16(bmp.dimensions().width,
                            bmp.dimensions().height/4)),
                    col);
col = bmpa_color::purple;
col.channelr<channel_name::A>(.5);
draw::filled_rectangle(bmp,
                    srect16(
                        spoint16(bmp.dimensions().width
                                -bmp.dimensions().width/4,
                                0),
                        ssize16(bmp.dimensions().width/4,
                            bmp.dimensions().height)),
                    col);

请原谅格式。这是我能做的最好的,以避免更糟糕的换行。基本上,我们在这里做的是从带有 alpha 通道的像素创建颜色,然后将 alpha 通道设置为一半,然后绘制一个填充的矩形以覆盖位图上已有的任何内容,混合颜色。ILI9341 演示中有一个这个的例子。其他演示由于屏幕尺寸限制则没有。

思考矩形

我们上面已经大量使用了矩形。如我所说,我们有有符号和无符号矩形,但您可以通过强制转换在它们之间进行转换。它们还有一套操作方法。虽然矩形本身是可变的,但这些函数不会修改矩形,而是返回一个新矩形,例如,如果您 offset() 一个矩形,函数将返回一个新矩形。也就是说,对于其中的一些,存在 XXXX_inplace() 对,它们会修改现有矩形。

一些函数接受一个目标矩形,并将其用作绘制方向的提示。draw::arc<>() 是其中一个方法。draw::bitmap<>() 是另一个。您可以使用 flip_XXXX() 方法更改矩形的方向,并使用 orientation() 访问器将方向检索为标志。大多数绘图操作——甚至是直线和椭圆——都使用矩形作为输入参数,因为它们的灵活性。习惯使用它们,因为那个双坐标结构中 Packed 了大量功能。

使用路径规划

路径只是一个点的序列,但它们在我们可以用它们做什么方面变得有趣。我们可以找到一系列点的边界矩形,并确定某个对象是否与其相交,无论它代表的是简单的线段系列还是多边形。为了效率,您提供缓冲区,但然后像 spath16 这样的类型会用一个 API 来包装它,该 API 允许偏移*、相交确定和边界计算。

* 偏移仅支持就地修改,因为复制路径的开销很大,我希望避免随意这样做。

位图,位图和更多位图

将位图视为存储像素数据的变量会有所帮助。

它基本上是一个内存中的绘图源和绘图目标。

孤立来看,它除了以适合帧缓冲区的格式存储像素之外,没什么用处,但由于其数据可以高效地传输到显示设备和其他位图,因此它成为了 GFX 的极其实用的功能。

最简单来说,它基本上是一个像素数组,并带有宽度和高度,但这并不完全准确。并非所有像素的宽度都是 8 位的倍数,更不用说偶数机器字大小了。帧缓冲区可能不对齐到字节边界。例如,如果您有一个 18 位像素,这意味着在位图内存中每 18 位就有一个新像素。因此,根据像素格式,不一定总是有简单的方法可以将位图访问为原始内存,但位图提供了访问和设置其中数据的方法。

位图不自行存储缓冲区。它们基本上是一个包装器,围绕您声明的缓冲区,将其转换为绘图源/目标。它们不自行存储缓冲区的原因是并非所有内存都均等。您有堆栈,然后可能有多种类型的堆,包括不能用于 DMA 传输的堆,例如 ESP32 WROVER 上的外部 4MB PSRAM。

我喜欢将固定大小的位图缓冲区声明在全局作用域(如果不想污染,则放在命名空间下),这样它们就不会放在堆栈上,我也不必管理堆。此外,早分配意味着将来碎片化的可能性更小。我知道人们会皱眉看待全局变量,我理解其中的原因,但在这些小设备上,它们在某些情况下很有用。我认为这是一种情况,但您的里程可能有所不同。

总之,我们首先需要声明我们的缓冲区。我非常小心地使我的对象支持 constexpr,因此您可以执行以下操作:

using bmp_type = bitmap<rgb_pixel<16>>;
// the following is for convenience:
using bmp_color = color<typename bmp_type::pixel_type>; // needs GFX color header

接着

constexpr static const size16 bmp_size(16,16);
uint8_t bmp_buf[bmp_type::sizeof_buffer(bmp_size)];

老实说,我第一次写这样的代码时,我很惊讶它能编译。现代 C++ 编译器真是个奇迹。在糟糕的旧时代,我常常因为 C 和 C++ 拒绝允许您将数组大小声明放在函数调用后面而感到沮丧,无论函数多么微不足道。我知道原因,但这并没有减轻我的沮丧感。sizeof_buffer() 计算的表达式是 (width*height*bit_depth+7)/8。它返回以该大小和颜色分辨率存储您的位图数据所需的最小完整字节数。

现在我们有了所有这些,用位图包装它非常简单:

bmp_type bmp(bmp_size,bmp_buf);
// you'll probably want to do this, but not necessary if 
// you're redrawing the entire bmp anyway:
bmp.clear(bmp.bounds()); // zero the bmp memory

现在您可以调用 draw 方法,并将 bmp 作为目标传递:

 // draw a happy face

// bounding info for the face
// change the line below if you want a subregion
srect16 bounds=(srect16)bmp.bounds();
rect16 ubounds=(rect16)bounds;

// draw the face
draw::filled_ellipse(bmp,bounds,bmp_color::yellow);

// draw the left eye
srect16 eye_bounds_left(spoint16(bounds.width()/5,
                                bounds.height()/5),
                                ssize16(bounds.width()/5,
                                        bounds.height()/3));
draw::filled_ellipse(bmp,eye_bounds_left,bmp_color::black);

// draw the right eye
srect16 eye_bounds_right(
    spoint16(
        bmp_size.width-eye_bounds_left.x1-eye_bounds_left.width(),
        eye_bounds_left.y1
    ),eye_bounds_left.dimensions());
draw::filled_ellipse(bmp,eye_bounds_right,bmp_color::black);

// draw the mouth
srect16 mouth_bounds=bounds.inflate(-bounds.width()/7,
                                    -bounds.height()/8).normalize();
// we need to clip part of the circle we'll be drawing
srect16 mouth_clip(mouth_bounds.x1,
                mouth_bounds.y1+mouth_bounds.height()/(float)1.6,
                mouth_bounds.x2,
                mouth_bounds.y2);

draw::ellipse(bmp,mouth_bounds,bmp_color::black,&mouth_clip);

现在您可以将该位图 draw::bitmap<>() 到您的显示器或另一个位图,或者任何绘图目标。您甚至可以从位图(或其他绘图源)绘制到自身,只要有效的源和目标矩形不重叠。如果它们重叠,数据可能会损坏。

所以现在这个 bmp 基本上是一个指向绘图目标的变量,该目标当前包含一个笑脸。很酷。

bitmap<> 模板类的单个成员并不那么重要。重要的是位图既是绘图源,也是绘图目标,因此可以与 draw 函数一起使用。

大型位图

在小型设备上,堆内存不多,尽管似乎您应该能够在 512kB 系统上加载 160kB 的帧缓冲区,但存在堆碎片这个不方便的小问题。在许多情况下,由于过去的解分配,堆碎片导致堆上没有 160kB 的连续可用空间,即使这是您在 RTOS 将控制权交给您之后进行的第一次分配,因为堆已经“脏”且已碎片化。我们该怎么办?

在 GFX 中,可以使用 large_bitmap<> 模板类创建大型位图。它是许多小型位图的组合,它呈现了一个统一的绘图源/目标的界面。就 GFX 而言,使用它与常规位图几乎相同,尽管它没有位图那么多的高级成员。我将根据需要添加和优化大型位图 API,但我希望将其发布出来。

创建它的方式与普通位图相同,只是您需要将段高度(以行为单位)作为第二个参数传递。大型位图由许多相同宽度的较小位图(称为段)组成,垂直堆叠。因此,每个段都有一定数量的行高。除最后一个段(可能较小)外,每个段都有相同数量的行。与普通位图不同,您不必为它分配自己的内存,但如果需要特殊的平台特定堆选项,您可以使用自定义分配器和解除分配器函数。不值得深入研究代码,因为使用它与使用常规位图没有区别,除了 GFX 会绕过缺乏的功能。

视口

视口允许您在可旋转或偏移的绘图目标上创建虚拟画布。它们的使用非常简单,尽管旋转可能很棘手,因为您必须正确设置旋转 center 才能获得预期的结果。

viewport<bmp_type> view(bmp);
view.rotation(90);    
view.offset({45,5});
// now draw
srect16 sr = view.translate(textsz.bounds());
sr = sr.clamp_top_left_to_at_least_zero();
draw::text(view, sr, {0,0}, text, fnt, sc, color_max::white);

我省略了一些代码,但基本上我们在这里做的是在位图上创建一个 viewport<>,将其旋转设置为 90 度。然后我们偏移一些,这样我们就不会裁剪即将绘制的文本,然后转换文本边界以获得合适的的目标矩形,最后,绘制到视口。所有绘图操作都将围绕 center 旋转 90 度,默认情况下为 (0, 0)。

正确使用异步绘图

每个绘图方法都有一个异步对应项。虽然它们存在,但通常不建议使用它们。但是,在某些情况下,使用它们可以显著提高您的帧率。这种情况有些狭窄,但可以复制。性能的关键是 draw::bitmap_async<>()。此方法对支持它的驱动程序是 DMA 感知的,并且将在驱动程序可用时通过帧写入在后台启动 DMA 传输。这通过驱动程序的 copy_from_async<>() 实现,但为了最高效率,不能对所讨论的位图进行裁剪、调整大小、翻转或颜色转换——否则将执行(大部分)同步操作。此外,您可能无法为这些传输使用 PSRAM——您仅限于可用于 DMA 的 RAM。最后,位图实际上必须足够大才能使其有价值。

多大的尺寸才有价值?这取决于。想法是您希望在后台发送您帧的一部分以供绘制,同时您正在渲染下一部分帧。为了使它起作用,您将不得不调整发送的位图大小,但我发现每 10kB(320x16x16bpp)左右 @ 26MHz 是一个胜利。通常,一次传输更多数据更好,但需要更多 RAM。

至少在 ESP-IDF 下,代码大致如下:

uint16_t *lines[2];
//Allocate memory for the pixel buffers
for (int i=0; i<2; i++) {
    lines[i]=(uint16_t*)heap_caps_malloc(lcd.dimensions().width
        *PARALLEL_LINES*sizeof(uint16_t), MALLOC_CAP_DMA);
    assert(lines[i]!=NULL);
}
using lines_bmp_type = bitmap<typename lcd_type::pixel_type>;
lines_bmp_type line_bmps[2] {
    lines_bmp_type(size16(lcd.dimensions().width,PARALLEL_LINES),lines[0]),
    lines_bmp_type(size16(lcd.dimensions().width,PARALLEL_LINES),lines[1])
};

int frame=0;
//Indexes of the line currently being sent to the LCD and the line we're calculating.
int sending_line=-1;
int calc_line=0;

// set up lines[] with data here...

++frame;
for (int y=0; y<lcd.dimensions().height; y+=PARALLEL_LINES) {
    //Calculate some lines
    do_line_effects(line_bmps[calc_line], y, frame, PARALLEL_LINES);
    // wait for the last frame to finish. Don't need this unless transactions are > 7
    if(-1!=sending_line)
        draw::wait_all_async(lcd);
    //Swap sending_line and calc_line
    sending_line=calc_line;
    calc_line=(calc_line==1)?0:1;
    //Send the lines we currently calculated.
    // draw::bitmap_async works better the larger the transfer size. Here ours is pretty big
    const lines_bmp_type& sending_bmp = line_bmps[sending_line];
    rect16 src_bounds = sending_bmp.bounds();

    draw::bitmap_async(lcd,(srect16)src_bounds.offset(0,y),sending_bmp,src_bounds);
    //The lines set is queued up for sending now; the actual sending happens in the
    //background. We can go on to calculate the next lines set as long as we do not
    //touch lines[sending_line] or the bitmap for it; 
    // the SPI sending process is still reading from that.
}

我的基本想法是,正如我所说的,我们有两个位图,我们绘制到一个,这里使用 do_line_effects()——想法是它用一些漂亮的颜色填充当前帧(由 calc_frame 指示)。然后,我们确保等待任何挂起的操作完成。原因是我们现在要开始写入另一个 line_bmps[] 位图,我们需要确保它仍在后台被读取。第一次通过,如 sending_line==-1 所示,我们跳过此等待步骤,因为我们尚未发送任何内容。

接下来,我们交换位图的索引,所以正如我所说的,我们现在将绘制到另一个。然后我们只需获取其边界,并将它与第一个位图一起传递给 draw::bitmap_async<>(),它将在您的硬件上全力进行 DMA(假设您的硬件能够胜任),在后台进行,并在调用后几乎立即释放循环,以便我们可以再次开始绘制,而不是等待传输完成。

这有点复杂,我正在考虑一些方法使其更容易实现这种模式。我会及时向您更新。应该注意的是,这项技术并非 GFX 所独有,也不是我发明的。事实上,当您需要实时双缓冲动画但又没有内存来容纳整个帧时,这是一种常见的渲染技术,也是一种更有效地进行动画的后台流式传输方式。

其他 XXXX_async 方法怎么样?

这些方法不太有效。缺乏阻塞不足以弥补如此小交易的开销。使用它们的主要原因是,在极少数情况下,您希望在 draw::bitmap_async<>() 之后继续排队绘图操作。通常,当您从异步切换到同步方法时,驱动程序必须等待所有挂起的异步操作完成。通过继续使用 line_async<>() 而不是 line<>() 来进行异步链,您可以防止强制等待位图数据完成,但会付出一些额外的 CPU 开销。

加载图像

有两种方法可以从 JPG 流(其他格式即将推出)中获取图像。这两种方法都需要创建输入流(如文件),然后将其与两种方法之一一起使用:

第一种也是最简单的方法是使用 draw::image<>(),它允许您将图像定位在目标上并裁剪图像的一部分。有一个调整大小的选项,但目前不支持。实际上,渐进式实现非常困难,所以我不知道何时会支持。目前,如果您尝试传递除 bitmap_resize::crop 之外的任何内容,它将返回 gfx_result::not_supported。同样,目前目标矩形的朝向被忽略,因此无法翻转。我将在可以处理时更新此信息——我有很多事情要忙。

在下面的代码中,lcd 代表我们在其上绘制图像的目标。

file_stream fs("/spiffs/image.jpg");
// TODO: check caps().read to see if the file is opened/readable
draw::image(lcd,(srect16)lcd.bounds(),&fs,rect16(0,0,-1,-1));

请注意,由于我们不知道位图的大小,因此我们可以将 0xFFFF 或 -1 作为范围,源矩形的范围将基于图像大小,因为源矩形将被裁剪以适合图像。

加载图像的第二种方法是将流传递给图像加载函数以及一个回调(我更喜欢使用匿名方法/lambda 来实现这一点),该回调处理渐进式加载。您会被调用多次,每次都提供一个位图形式的图像部分,以及它在图像中的位置,以及您传递给加载函数的任何状态。请注意,为了减少开销,使用状态变量来传递状态而不是使用 std::function 这样的函数对象。您可以使用“扁平”lambda,它会衰减为简单的函数指针,然后将您的类指针作为 state 参数传递,并在您的回调中进行重组。通常,您甚至不需要状态参数,因为您需要的所有内容,例如显示器本身,都可以在全局范围内使用。

file_stream fs("/spiffs/image.jpg");
// TODO: check caps().read to see if the file is opened/readable
jpeg_image::load(&fs,[](size16 dimensions,
                        typename jpeg_image::region_type& region,
                        point16 location,
                        void* state){
    return draw::bitmap(lcd, // lcd is available globally
                        srect16((spoint16)location,
                                (ssize16)region.dimensions()),
                                region,region.bounds());
},nullptr);

加载(或嵌入)字体

字体可以与 draw::text<>() 一起使用,并且可以从流加载(类似于图像),或者可以通过生成 C++ 头文件将其嵌入到二进制文件中。您选择哪种方式取决于您的需求和您愿意放弃什么。在物联网领域,一切都是关于拆东墙补西墙。

总之,除非您打算能够在运行时加载和卸载字体,或者程序空间比 SPIFFs 和 RAM 更宝贵,或者您想能够从 SD 卡加载 _*.FON_ 或 _*.TTF_ 文件,否则您通常会选择嵌入式字体。

说到 _*.FON_ 文件,它们是旧的(主要是)光栅字体格式,源自 Windows 3.1 时代。鉴于当时是 16 位系统,_*.FON_ 文件非常直接,开销很小,并且设计为可以快速读取。此外,虽然它们很老,但至少它们不是完全专有的格式。可以在网上找到它们,甚至可以自己制作。对于这些设备来说,_*.FON_ 文件是一种近乎理想的格式,这就是它们被选择在这里的原因。在物联网领域,一切旧的都焕然一新。

您也可以使用 .TTF 文件,它们更灵活、更美观、更现代,但您将付出巨大的性能和复杂性代价。

您可以使用 _fontgen_ 工具从字体文件创建头文件。只需将其包含在内即可嵌入,然后在您的代码中引用该字体。字体是一个全局变量,名称与文件名相同(包括扩展名),非法标识符字符被替换为下划线。

让我们谈谈第一种方法——嵌入:

首先,在 GFX 库的 _tools_ 文件夹下,使用 fontgen 从字体文件生成头文件。

~$ fontgen myfont.fon > myfont.hpp

C:\> fontgen myfont.ttf > myfont.hpp

请注意,在 Windows 上,它可能会尝试以 UTF-16 格式输出,这将严重损坏您的头文件。如果发生这种情况,请在记事本中打开头文件,并将其另存为 ASCII 或 UTF-8。还请注意,在 fontgen 源代码中,在 Windows 平台上应设置 #define WINDOWS

现在您可以将其包含在您的代码中:

#include "myfont.hpp"

这样您就可以像这样引用字体:

光栅字体
const font& f = myfont_fon;
const char* text = "Hello world!";
srect16 text_rect = f.measure_text((ssize16)lcd.dimensions(),
                        text).bounds();
draw::text(lcd,
        text_rect.center((srect16)lcd.bounds()),
        text,
        f,
        lcd_color::white);

访问字体的第二种方法是从流加载 _*.FON_ 文件,该文件将字体存储在堆上,而不是嵌入到您的代码中作为 static const 数组,只需将上面的第一行代码替换为:

file_stream fs("/spiffs/myfon.fon");
if(!fs.caps().read) {
    printf("Font file not found.\r\n");
    vTaskDelay(portMAX_DELAY);
}
font f(&fs);

这将从给定文件在堆上创建一个字体。然后您可以像往常一样绘制它。当它超出范围时,它使用的堆将被回收。

用实心背景绘制字体通常比用透明背景绘制字体更有效,所以如果原始性能是您的最终目标,请坚持使用非透明字体绘制。

True Type/Open Type 字体
const open_font& f=Maziro_ttf;
draw::filled_rectangle(lcd,(srect16)lcd.bounds(),lcd_color::white);
const char* text = "ESP32 GFX Demo";
float scale = f.scale(40);
srect16 text_rect = f.measure_text((ssize16)lcd.dimensions(),{5,-7},
                        text,scale).bounds();
draw::text(lcd,
        text_rect.center((srect16)lcd.bounds()),
        {5,-7},
        text,
        f,
        scale,
        lcd_color::dark_blue);

与光栅字体相比,请注意偏移和比例参数的添加。

文件与加载光栅字体相同。

GFX 绘图绑定

驱动程序和其他对象可以是绘图目标,也可以是绘图源。为了作为这些对象工作,自定义绘图目标必须公开一些成员,以便 GFX 可以绑定到它们。

所有绘图目标共有的成员

第一个成员是一个公共 using caps = gfx::gfx_caps<...>; 别名,用于确定您的驱动程序支持哪些功能。例如,如果您指示支持批处理,GFX 将尝试在您的驱动程序上调用 write_batch() 等方法。如果您指示支持某个功能但未实现相应的方法,则会导致编译错误。

  • caps - 指示目标的性能,这些性能包括以下成员:
    • blt - 目标支持使用 begin() 将其内存访问为原始数据,并且其内存必须从左到右、从上到下以 pixel_format 的形式连续排列。
    • async - 目标支持其方法的异步版本。当调用者请求异步操作时,GFX 将调用以 _async() 结尾的方法。如果它不支持所有方法,而只支持其中一些,那么实现者应该从不支持的 _async() 操作委托到同步操作,以处理没有异步对应项的方法。
    • batch - 目标支持批次写入操作,并需要公开 begin_batch()write_batch()commit_batch()
    • copy_from - 目标支持从绘图源进行优化复制,GFX 应在可能的情况下使用公开的 copy_from<>() 模板方法。
    • suspend - 目标支持粒度双缓冲,其中当调用 suspend() 时,绘图操作可以写入屏幕外,然后在 resume() 时将更新后的屏幕部分发送到显示器。实现者应维护挂起调用计数,以平衡与 resume 调用的数量。
    • read - 目标支持读取像素数据,这有利于其用作绘图源并启用 Alpha 混合。
    • copy_to - 目标支持到绘图目标的优化复制,GFX 应在可能的情况下使用公开的 copy_to<>() 方法。如果可以选择使用绘图源的 copy_to<>() 方法和绘图目标的 copy_from<>() 方法,GFX 将选择 copy_from<>() 方法,因为该方法有更多的优化机会。

接下来,您必须在绘图目标上声明 using pixel_type 别名。这很可能是 gfx::rgb_pixel<16>(用于彩色显示器)和 gfx::gsc_pixel<1>(用于单色(1 位灰度)显示器)的别名。它告诉 GFX 您的绘图对象的原生格式是什么。

  • pixel_type - 指示此目标的原生像素格式。

如果您的像素类型是索引的,意味着它包含 channel_name::index,您必须包含 palette_type 别名作为您的调色板类型。对于像电子纸显示器这样的驱动程序,它们通常会有一个关联的调色板类,这个别名就指向它。

  • palette_type - 指示 pixel_type 指向索引像素时关联的调色板类型。

现在您可以开始实现所需的方法了。大多数方法都返回 enum gfx::gfx_result,表示操作的状态。

首先,除了 capspixel_type 别名之外,还有一些您必须实现的成员:

  • size16 dimensions() const - 返回一个 size16,指示绘图目标的尺寸。
  • rect16 bounds() const - 返回一个 rect16,其左上角为 (0,0),宽度和高度等于绘图目标的宽度和高度。这是 dimensions().bounds() 的别名。

接下来,如果您的 pixel_type 指向索引像素,您必须实现一个 palette() 方法,该方法返回指向与您的绘图目标关联的调色板的指针。

  • const palette_type* palette() const - 返回指向与此绘图目标关联的调色板的指针。

绘图源成员

要将目标实现为绘图源,您还必须实现一个或可能两个方法:

  • gfx_result point(point16 location, pixel_type* out_color) const - 检索指定位置的像素。
  • gfx_result copy_to<typename Destination>(const rect16& src_rect,Destination& dst,point16 location) const - 将目标的一部分复制到指定位置的指定目标。

如果您实现了 copy_to<>(),请确保设置 caps 中的相应条目,以便 GFX 调用它。

无论如何,现在您可以将其用作 draw::bitmap<>() 等调用的源。

绘图目标成员

由于需要各种优化才能在设备驱动程序上获得良好性能,因此实现绘图目标可能比实现绘图源复杂得多。

除通用方法外,您必须实现的最低要求是前两个方法,但之后的方法是可选的,可以提高性能。为了节省空间,我不会列出_async()方法,因为它们的名称和签名是从同步方法派生的。

  • gfx_result point(point16 location, pixel_type color) - 在指定位置设置一个像素
  • gfx_result fill(const rect16& rect, pixel_type color) - 在指定位置填充一个矩形

以上是最低要求。其余的要求取决于caps设置。

对于高性能位图数据复制,一个重要的方法是copy_from<>()

  • template<typename Source> gfx_result copy_from(const rect16& src_rect,const Source& src,point16 location) - 从一个绘图源复制到绘图目标。

此方法应确定采取何种最佳操作,以便尽快将src的数据发送到此目标。它不应使用源的copy_to<>()方法,但除此之外,它可以使用任何方法。通常的做法是内部调用另一个模板,该模板在Source::pixel_typepixel_type相同时进行专门化,并且源可以进行blt。然后可能进行原始内存读取。如果是这样,您可能能够发起DMA传输,例如。然后,您必须有回退方案,以防不支持。

接下来我们将介绍批量处理。如果您支持批量操作,则需要实现以下三个方法:

  • gfx_result begin_batch(const rect16& rect) - 在指定的矩形处开始一个批量操作。
  • gfx_result write_batch(pixel_type color) - 将像素写入当前批次。像素按照从左到右、从上到下的顺序写入批次矩形。如果像素缓冲区已满,可以根据需要发送。批次不会暂停,它只是一种减少通信量的方式。
  • gfx_result commit_batch() - 发出所有剩余的批处理数据并退出批量模式。

实现批量处理时,请确保如果您在批量操作中间开始一个非批量操作,或者开始一个新的批量操作,则会自动提交当前批次。

如果您支持暂停/恢复(双缓冲),则需要实现以下两个方法:

  • gfx_result suspend() - 暂停绘图。暂停是计数的且平衡的,因此每次调用suspend,您都必须调用相应的resume()

  • gfx_result resume(bool force=false) - 恢复绘图,可以选择丢弃所有先前的suspend()并强制恢复绘图。屏幕在调用最终的resume时更新。

最后,所有写入方法都有一个可能的_async()对应方法,它接受相同的参数,但将操作加入队列并尽快返回。目前没有提供异步读取的机制,但未来将有所改变。

下一步

附带的演示项目应该为您提供了充足的代码来学习GFX甚至构建自己的驱动程序。目前,我专注于支持Arduino,但GFX本身不受平台限制,驱动程序可以为任何东西编写——甚至可以在Windows PC上编写DirectX驱动程序。

历史

  • 2021年5月10日 - 首次提交
  • 2021年5月16日 - 添加了驱动程序、配置、接线指南
  • 2021年5月17日 - 添加了更多驱动程序
  • 2021年5月18日 - 为draw::bitmap<>()添加了精灵/透明颜色支持
  • 2021年5月21日 - 错误修复并添加了另一个驱动程序
  • 2021年5月21日 - draw::bitmap<>()错误修复
  • 2021年5月24日 - 修复了某些演示的构建错误
  • 2021年5月27日 - 添加了Alpha混合支持
  • 2021年5月29日 - 添加了large_bitmap<>支持、API更改、演示更改
  • 2021年5月31日 - API清理并添加了路径和多边形支持
  • 2021年5月31日 - 修复了几个构建错误
  • 2021年6月1日 - 添加/修复了位图调整大小选项,并为图像回调添加了尺寸
  • 2021年6月5日 - 添加了单头文件,以及更易于使用的图像加载。稍微清理了定位API。修复了draw::中裁剪矩形参数声明的错误
  • 2021年6月7日 - 服务版本。某些绘图操作在某些绘图目标之间可能会编译失败
  • 2021年6月8日 - 添加了调色板/CLUT支持(初始/实验性)
  • 2021年6月8日 - 服务版本。修复了large_bitmap<>越界崩溃问题
  • 2021年6月13日 - 添加了Arduino框架支持和几个基于Arduino的驱动程序
  • 2021年6月15日 - 添加了对两种电子墨水/电子纸显示器的支持:DEP0290B(以及相关的LilyGo T5 2.2板)以及GDEH0154Z90(WaveShare 1.54英寸三色黑/白/红色显示屏)。
  • 2021年6月17日 - 为电子墨水/电子纸显示器添加了抖动支持
  • 2021年7月13日 - 添加了TrueType字体支持
  • 2021年7月30日 - 服务版本 - 修复了stream.hpp的错误,并更新了platformio.ini以构建更新的ESP-IDF
  • 2021年11月13日 - 一个错误修复,以及RA8875驱动程序和viewport<>模板的添加
  • 2021年11月15日 - 对RA8875驱动程序进行了性能和功能改进,并对viewport<>模板进行了改进
  • 2021年12月8日 - 重构了库,修复了错误,并添加了TFT_eSPI绑定
  • 2021年12月9日 - 修复了在某些环境下编译的编译器错误,添加了DirectX原型支持。
  • 2022年2月9日 - 为Arduino驱动程序添加了新的驱动程序结构,具有解耦的总线框架、更好的SPI性能和8位并行支持。
  • 2022年2月13日 - 将除电子纸和RA8875之外的所有其他显示器移植到Arduino的新驱动程序代码。
  • 2022年2月17日 - 添加了Wio Terminal支持,并使GFX更加跨平台友好。
  • 2022年2月18日 - 简化了总线框架,并修复了RA8875触摸屏错误。
  • 2022年2月20日 - 改进了多个驱动程序的读取速度,加快了Alpha混合,并修复了一些编译错误
  • 2022年3月9日 - 添加了Waveshare 5.65英寸彩色电子纸支持。在draw::text<>()中添加了“无抗锯齿”绘图选项
© . All rights reserved.