ESP32+GC9D01圆形屏写轮眼动画完整实现
发布时间:2026/9/28 16:51:20来源:尧图网络
最近朋友送了我一块1.28寸的圆形屏幕主控是GC9D01一直想找个有意思的玩法。写轮眼这事儿琢磨了挺久毕竟是火影里辨识度最高的视觉符号之一做成动画循环播放放在桌面上当个摆件挺带感的。ESP32配GC9D01这种圆形屏其实很适合做这类圆对称的图案动画不光是写轮眼表盘、进度环、雷达扫描这类UI都能用同一套思路。这篇博客就把我完整实现的过程拆开讲从硬件接线、驱动适配到静态绘制、动画循环一共5步最后附上我踩过的几个坑。先说明一下这个项目不需要什么高深算法会写一点Arduino代码就能跟下来。核心就三件事让屏幕正确显示驱动适配、把写轮眼画出来几何计算、让勾玉转起来动画循环。我会尽量把每一步为什么这么做讲清楚而不是只给代码。想直接抄作业的代码片段也能直接复制。1. 项目思路与整体设计1.1 为什么选ESP32GC9D01这套组合先聊为什么选这两块硬件。ESP32在DIY圈子里几乎是标配级的存在240MHz双核处理这类绘图任务绰绰有余驱动一块小LCD完全不会卡。更重要的是它的外设接口很全SPI、I2C、UART都有后续想加个旋钮调转速、加个传感器触发特殊效果都不用换主控。GC9D01则是圆形LCD里最常见的一颗驱动IC市面上1.28寸、240x240分辨率的圆屏大多数用的是它。这颗IC最大的特点就是圆形裁切已经由面板本身完成了驱动层面其实还是一块240x240的方形屏只是四个角的像素是物理不存在的显示黑色。所以编程时你不需要做任何圆形裁剪处理直接用方形坐标系往里填像素就行这大大降低了开发难度。选圆形屏而不是方形屏是出于场景考虑。写轮眼的图案天然是圆对称的圆形屏幕的边界会和虹膜外圈完美贴合视觉上很有整体感。如果拿方形屏做还得在角落里留黑边出不来那种“全屏都是眼睛”的沉浸效果。另外圆形屏做表盘、仪表盘这类视觉场景也特别合适一次调通驱动以后复用率很高。1.2 动画实现的技术选型动效方案上我对比过两条路一是直接用TFT_eSPI的绘图API在屏幕上实时画二是先在内存里建一块240x240的画布Sprite画完再一次性推送到屏幕。前者简单直接适合图案不复杂、刷新频率不高的场景后者能避免屏幕闪烁代价是占用约115KB内存。写轮眼动画说白了就是三个勾玉绕中心旋转外加瞳孔和虹膜图层基本不动。如果你把整个场景每次全屏重绘用第一种方案大概可以跑到20FPS左右肉眼看不出闪烁但如果之后想加动态模糊、残影、渐变这些特效帧率会直线下降那时候还是得上Sprite。我的建议是先用直接绘制把逻辑跑通等动画结构稳定了再考虑引入Sprite优化。这也是我这篇文章采用的路径——代码简单逻辑清晰适合新手理解动画循环的机制。顺带提一下库的选择。TFT_eSPI是目前ST7789、ILI9341、GC9D01这些SPI屏幕用得最多的库社区资料丰富配置集中在User_Setup.h一个文件里改起来比较直观。备选方案是LovyanGFX性能更强底层优化做得更好但API风格差别不小二选一即可不建议混着学。2. 硬件搭建与驱动适配2.1 接线与引脚选择先把硬件连接搞定。ESP32 DevKitC V4的开发板加上一块常见的GC9D01圆屏模块模块上一般有8个引脚GND、VCC、SCL、SDA、RES、DC、CS、BLK。接线顺序照着SPI的习惯来就行屏幕引脚ESP32引脚说明VCC3.3V或5V看模块部分模块带稳压芯片可接5VGNDGND共地SCLGPIO18SPI时钟SDAGPIO23SPI主出从入MOSIRESGPIO4复位低电平有效DCGPIO2数据/命令选择CSGPIO5片选BLKGPIO32背光控制建议接GPIO方便调光注意MISO线不用接这块屏幕是单向写屏不涉及读操作。如果你用的开发板引脚不是这组也没关系TFT_eSPI允许你自定义任意GPIO作为CS、DC、RST、BLK但SCK和MOSI最好还是用SPI外设默认引脚否则可能要走软件模拟SPI帧率会打折。电压问题提醒一下ESP32的IO输出是3.3V逻辑GC9D01模块大多支持3.3V逻辑输入可以直接连。但VCC如果标注5V一定要确认模块板上有没有稳压电路有些廉价模块直接给5V会烧背光。最稳妥的接法是VCC直接接3.3V大多数GC9D01模块在这个电压下亮度完全够用。2.2 TFT_eSPI驱动配置详解驱动适配是整个项目里最容易被卡住的一步但只要你搞清楚TFT_eSPI的配置文件结构也就没那么玄乎。Arduino IDE的库管理器里搜索“TFT_eSPI”直接安装然后找到库文件夹里的User_Setup.h用编辑器打开把下面这几段配置改好。首先由于我们用的是GC9D01需要启用GC9D01驱动宏定义并且把其他驱动宏定义全部注释掉。这个步骤非常关键如果同时开着ILI9341和GC9D01编译器会报错屏幕也会异常。#define GC9D01_DRIVER然后定义屏幕宽高。GC9D01是240x240这两个值不要填错#define TFT_WIDTH 240 #define TFT_HEIGHT 240接下来配置SPI引脚不同的开发板使用不同的引脚定义但同一块板子如果用默认的VSPI或HSPI通常改动不大#define TFT_MOSI 23 #define TFT_SCLK 18 #define TFT_CS 5 #define TFT_DC 2 #define TFT_RST 4 #define TFT_BL 32最后是SPI通讯频率。一般来说40MHz很稳妥我实测过手上的模块跑80MHz也没有问题但如果你发现屏幕出现雪花点、颜色错乱就把频率降回27MHz或20MHz试试#define SPI_FREQUENCY 40000000配置完成后先用最简单的代码点亮屏幕做一次驱动自检#include TFT_eSPI.h TFT_eSPI tft; void setup() { tft.init(); tft.fillScreen(TFT_RED); } void loop() { }烧录后如果看到整个圆形屏幕变成红色说明驱动适配完成可以进入下一步。如果屏幕亮了但颜色不对或者干脆白屏黑屏直接看后面第5章的排查表这里先不展开。2.3 备选库LovyanGFX适配如果你之后想做更复杂的动效比如带半透明混合、粒子效果建议直接上LovyanGFX。它的性能比TFT_eSPI强不少但对GC9D01的适配方式不太一样不是改一个User_Setup.h那么简单需要自己在代码里声明一个LGFX设备类。我可以给一个极简的配置参考方便你对比。这个配置本质是把SPI引脚和屏幕驱动IC告诉LGFX库#include LovyanGFX.hpp class LGFX : public lgfx::LGFX_Device { lgfx::Panel_GC9D01 _panel; lgfx::Bus_SPI _bus; public: LGFX() { _bus.spiformat 0; _bus.spi_mode 0; _bus.spi_freq 40000000; _bus.pin_sclk 18; _bus.pin_mosi 23; _bus.pin_miso -1; _bus.pin_dc 2; _panel.pin_cs 5; _panel.pin_rst 4; _panel.panel_width 240; _panel.panel_height 240; _panel.setBus(_bus); } }; LGFX tft;配置完用tft.init()和tft.fillScreen(TFT_RED)同样可以自检。两套库选择的本质区别在于如果只是做静态UI和简单旋转动画TFT_eSPI足够资料多、出问题好搜如果你打算做复杂的Shader效果、3D投影或者流畅度要求奇高再去啃LovyanGFX。3. 5步搞定写轮眼动画3.1 第1步创建工程并完成驱动测试在Arduino IDE里新建工程选择开发板为“ESP32 Dev Module”。把上一章的驱动测试代码烧进去确认屏幕能正常显示纯色画面。这一步表面上看很无聊但花两分钟做一次能帮你把所有硬件问题提前暴露省得后面写了一大堆绘图代码最后发现是屏幕本身没亮排查起来全是噪音。测试时建议分别检查几种颜色红色、绿色、蓝色、白色、黑色。如果只是纯红色正常但绿色和蓝色全都不对大概率是RGB通道序问题后面会说。如果所有颜色都是乱的优先检查MOSI和SCLK是不是接反了这是最常见的低级错误其次检查CS和DC引脚是否配置正确。3.2 第2步定义坐标系与基础图层GC9D01的逻辑分辨率是240x240坐标系原点在左上角向右为X轴正方向向下为Y轴正方向。虽然屏幕是圆的但坐标范围方方正正一个像素都不会少。写轮眼本身的图案是圆对称的所以我把圆心定在屏幕正中心const int cx 120; const int cy 120; const int outerR 108; // 外圈红色轮廓半径 const int irisR 96; // 虹膜红色区域半径 const int pupilR 16; // 瞳孔黑色区域半径 const int tomoeR 60; // 勾玉所在圆周半径为什么外圈半径不直接填120因为屏幕的边缘就是物理裁剪线如果把圆画满到半径120像素会顶到圆形边界看起来好像被切掉一块边缘也比较毛糙。留出10来个像素的余量视觉效果反而更好这点在圆形屏幕上特别明显。图层关系从下往上依次是黑色背景、红色外圈、深红虹膜、黑色瞳孔、三个勾玉。代码顺序必须严格按这个先后画后画的会覆盖先画的所以勾玉必须放在最后。3.3 第3步绘制写轮眼静态图案先画所有圆形基础层代码很直白tft.fillScreen(TFT_BLACK); tft.fillCircle(cx, cy, outerR, TFT_RED); tft.fillCircle(cx, cy, irisR, 0x7800); // 深红色 tft.fillCircle(cx, cy, pupilR, TFT_BLACK);0x7800是RGB565格式下的深红色相当于RGB(128, 0, 0)。如果你想让写轮眼更有层次可以在红色外圈和深红虹膜之间多画一圈过渡色比如用0xB800偏亮的暗红制造渐变感。真正的难点是画勾玉。勾玉的形状可以拆成“头部圆点尾部弯曲”的组合。为了画得简单又有点味道我采用“头部大圆尾部小圆”的方式实现头部是一个半径7像素的实心黑圆然后在它的右上方或者左下角叠加一个小圆模拟尾巴弯曲的轮廓。勾玉的位置需要按照极坐标计算分布。三个勾玉均匀分布在以圆心为中心的圆周上彼此夹角120度。假设当前旋转角度是angle第i个勾玉的圆心坐标就是float a angle i * 2 * PI / 3; int x cx tomoeR * cos(a); int y cy tomoeR * sin(a);这个cos和sin是整个动画的核心理解这一行后面的旋转就是水到渠成的事。直接说结论你在圆周上选定一个半径tomoeR给定一个角度a就能算出圆周上对应点的X、Y坐标。角度从0变到2π这个点就会绕圆心转一圈。三个勾玉共享同一个angle每隔120度放一个看起来就是一组整体旋转的图案。为了更好看画勾玉时还要传入一个方向角让勾玉的“尾巴”朝圆心方向弯曲void drawTomoe(int x, int y, float dir) { tft.fillCircle(x, y, 7, TFT_BLACK); // 头部大圆 int tx x 4 * cos(dir PI / 2); int ty y 4 * sin(dir PI / 2); tft.fillCircle(tx, ty, 3, TFT_BLACK); // 尾巴小圆 }dir就是当前勾玉所在位置的角度。简单的加一个90度偏移尾巴就会绕着头部圆垂直方向偏移形成自然的弯曲感。实际跑起来效果还是能看出是勾玉的但如果你追求高度还原动漫原画建议用多个半径细分填充或者在一张图片素材上做旋转。这里我们是代码生成派讲究的是“神似”够用就行。组合起来静态写轮眼的效果是红色外圈包着深红虹膜中间是黑洞洞的瞳孔三个黑色勾玉均匀分布在虹膜上朝向稍微倾斜。整体画面已经很有辨识度了。3.4 第4步勾玉旋转的动画循环动画的本质是每隔一小段时间微调角度然后重新绘制全屏。角度变勾玉位置就变视觉上就转起来了。我的循环结构是这样float angle 0; unsigned long lastUpdate 0; const float step 0.15; // 每次旋转的弧度增量 const unsigned long interval 50; // 每50毫秒更新一次 void loop() { unsigned long now millis(); if (now - lastUpdate interval) { angle step; if (angle TWO_PI) angle - TWO_PI; drawSharingan(angle); lastUpdate now; } } void drawSharingan(float angle) { tft.fillScreen(TFT_BLACK); tft.fillCircle(cx, cy, outerR, TFT_RED); tft.fillCircle(cx, cy, irisR, 0x7800); tft.fillCircle(cx, cy, pupilR, TFT_BLACK); for (int i 0; i 3; i) { float a angle i * 2 * PI / 3; int x cx tomoeR * cos(a); int y cy tomoeR * sin(a); drawTomoe(x, y, a); } }这里interval设置为50毫秒理论上每秒更新20次也就是20FPS。step为0.15弧度大约相当于8.6度所以每秒转过的角度是20乘以8.6度约172度差不多2秒转一圈。这个速度是写轮眼比较经典的转动速度不紧不慢。可能有朋友问为什么用millis()而不是delay()delay()会阻塞整个程序如果以后你想在响应的同时处理按键、传感器、蓝牙消息delay()会直接把所有事件卡死。millis()是非阻塞延时主循环该干嘛干嘛到时间才执行一次刷新这种写法在单片机动画里才是比较健康的模式。3.5 第5步效果打磨与色彩微调到这里核心动画已经能跑起来了但如果你仔细看会发现画面还是有点“平”。业余项目和拿得出手的作品差距往往体现在细节上。我做了几个小的打磨成本很低但视觉提升明显。给瞳孔加一抹高光。动漫角色的眼睛通常有一个白色的反光点我在瞳孔的左上角偏移位置画一个半径4像素的白色半透明圆一下子眼睛就有了“活”的感觉。tft.fillCircle(cx - 5, cy - 5, 4, TFT_WHITE);给虹膜增加渐变层次。GC9D01没有硬件渐变功能但可以用多个同心圆逐层收缩、逐层加深颜色来模拟。比如在红色外圈和深红虹膜之间从半径108到96每隔12个像素画一圈不同饱和度的红色看起来就有放射状渐变的味道。外圈加一圈黑边轮廓。这个看个人喜好如果你希望写轮眼从黑色背景里“跳”出来可以在outerR1的位置先画一圈黑色圆环再在outerR-2的位置重新画红色圆。这样会产生一个约2像素宽的描边效果屏幕边缘不再是一刀切的硬边。色彩微调方面每个人对“写轮眼红”的理解不一样。我调过好几版颜色最终用的是部位RGB565值视觉描述外圈红0xF800正红亮眼虹膜深红0x7800偏暗的猩红瞳孔黑0x0000纯黑高光白0xFFFF冷白你可以直接改这些常量找到自己最顺眼的色调。建议在调试时先静态画一版多用几组颜色对比确定后再上动画调起来效率更高。4. 性能优化与进阶扩展4.1 帧率瓶颈与SPI提速项目做到这一步应该已经能稳定跑在20FPS左右。如果你想要更丝滑或者后续加更多图层就需要关注性能了。全屏240x240的屏幕一帧的像素量是57600个每个像素需要传输2字节也就是一帧要往屏幕送115200字节。SPI频率40MHz时理论传输时间约为2.88毫秒但实际因为库的开销、填充函数的运算以及等待刷新时间单帧全屏重绘通常需要20到50毫秒这就是20FPS到40FPS的实际水平。想提速有几个方向。第一提高SPI频率把SPI_FREQUENCY从40MHz改成80MHz我和几个朋友交流下来多数GC9D01模块都能跑但极个别模块会出现噪点属正常体质差异测试为准。第二减少重绘面积比如背景和虹膜其实每一帧都不变只有勾玉在转如果做局部刷新性能会有明显提升但这需要引入脏矩形算法代码复杂度高不少。第三如果还是觉得不够再上Sprite把绘制和推送分离让绘制阶段不完全阻塞SPI传输。实际做小摆件的话20FPS已经够用动画眼睛转动讲究的是稳重不是电竞级刷新率没必要为了帧率牺牲代码可读性。4.2 使用Sprite减轻闪烁如果你在直接重绘时看到眼睛周围有轻微闪烁大概率是因为SPI传输和面板刷新交错导致的。解决方法就是用Sprite帧缓冲先把整帧画面画在内存里画完了再一次性推到屏幕面板。代码改动不大主要是把原来画到tft的地方改成画到sprite最后统一pushSpriteTFT_eSprite img TFT_eSprite(tft); img.createSprite(240, 240); img.setColorDepth(16); img.fillSprite(TFT_BLACK); void drawSharingan(float angle) { img.fillSprite(TFT_BLACK); img.fillCircle(cx, cy, outerR, TFT_RED); img.fillCircle(cx, cy, irisR, 0x7800); img.fillCircle(cx, cy, pupilR, TFT_BLACK); for (int i 0; i 3; i) { float a angle i * 2 * PI / 3; int x cx tomoeR * cos(a); int y cy tomoeR * sin(a); drawTomoeOnSprite(x, y, a); } img.pushSprite(0, 0); }createSprite需要115KB内存这正好是GC9D01一帧16位色所需的空间。ESP32普通版非PSRAM空闲内存一般在160KB到200KB之间能塞下但如果你同时开了WiFi、蓝牙堆内存会紧轻则编译失败重则运行中死机重启。最好用带PSRAM的模组比如ESP32-WROOM-32E-N16R2或者ESP32-S3的N8R8内存焦虑直接消失。我自己的习惯是先把逻辑跑通再上Sprite避免调试时黑屏找不到原因。你可以理解为Sprite是一层“优化补丁”而不是一开始就必须引入的复杂度。4.3 万花筒写轮眼快速实现动画能转起来之后我顺手做了几版万花筒写轮眼算是把代码复用的价值榨干了。和普通三勾玉写轮眼相比万花筒的绘制逻辑其实更简单因为它主要由对称的几何线条构成不需要模拟弯曲的勾玉。以宇智波鼬的万花筒为参考一个中心圆环加三条穿过圆心的弧线。用代码实现就是每隔60度画一条旋转对称线旋转的时候所有线条同时转动void drawMangekyou(float angle) { img.fillSprite(TFT_BLACK); img.fillCircle(cx, cy, 80, TFT_BLACK); img.drawCircle(cx, cy, 80, TFT_RED); img.drawCircle(cx, cy, 60, TFT_RED); img.drawCircle(cx, cy, 20, TFT_RED); for (int i 0; i 6; i) { float a angle i * PI / 3; int x1 cx 78 * cos(a); int y1 cy 78 * sin(a); img.drawLine(cx, cy, x1, y1, TFT_RED); } }效果有点像旋转的风车加上瞳孔的黑色圆辨识度也不低。这里想说的是你把圆形屏幕的基本绘制函数和极坐标计算逻辑吃透不同的动画形态只是改改半径、颜色、角度增量和对称数量而已。写轮眼的“转”万花筒的“闪”雷达扫描的“扫”本质上都是同一套数学模型。5. 常见问题与避坑指南5.1 白屏、黑屏与闪屏的排查这个项目我调试过程中遇到最多的问题基本集中在屏幕不亮、颜色不对和闪烁这三类。我把症状、原因和解决办法整理成了一张表大部分情况能直接对上号。症状可能原因解决办法白屏GPCD01驱动宏没启用或驱动IC初始化失败检查User_Setup.h只保留GC9D01_DRIVER白屏供电不足屏幕拉不起用独立3.3V供电不要全部靠USB口3.3V黑屏屏幕能亮但全黑检查BLK引脚是否接好或代码里没初始化背光黑屏有背光但无图像检查CS、DC、RST是否接对RST不能悬空闪烁直接重绘导致的刷新不同步用Sprite帧缓冲后pushSprite有残影SPI频率过高时序不稳定降低SPI_FREQUENCY到27MHz或20MHz补充一个容易被忽略的细节有些GC9D01模块的BLK引脚默认是悬空的需要软件拉高。如果你用的是我上面的接线方案BLK接GPIO32那么在tft.init()之后加一行pinMode(TFT_BL, OUTPUT); digitalWrite(TFT_BL, HIGH);确保背光打开。如果BLK接的是VCC这一步可以省。5.2 颜色显示异常的解决方法颜色对应关系不对是圆形屏项目里出现频率极高的问题。具体表现是屏幕能显示内容但画红色显示蓝色画绿色显示洋红色整体颜色像“反相”了一遍。这大概率是RGB和BGR通道顺序的问题。GC9D01这颗IC的默认颜色顺序在不同模块上并不统一有的出厂就是RGB有的是BGR。TFT_eSPI在User_Setup.h中提供了切换宏如果你遇到颜色错乱尝试注释掉或打开这行#define TFT_RGB_BGR改完重新编译烧录颜色应该就恢复正常了。如果这招没用确认一下是不是用了旧版本的库新版本TFT_eSPI对GC9D01的支持已经很完善但早期版本确实存在个别驱动宏缺失的bug。还有一种偏色是供电问题导致如果模块VCC接在5V、背光电流压降大屏幕会整体偏紫或偏青。改用3.3V供电后多数能解决这也呼应了前面接线那部分的建议。5.3 内存不足与联网冲突的避坑如果你只是点亮屏幕跑动画内存问题大概率碰不到。可一旦你在同一个工程里同时启用了WiFi和蓝牙事情就复杂了。ESP32的WiFi协议栈要占几十KB的RAM蓝牙也要吃一块加上GC9D01的帧缓冲内存池很容易见底。我遇到过的最典型的情况单独跑屏幕动画正常加了WiFi连接后动画开始随机闪烁、卡顿甚至循环重启。打印ESP.getFreeHeap()一看空闲堆内存只有不到20KB了这时候创建任何小对象都可能失败。解决方案有几个一是优先选带PSRAM的ESP32模组这是根治方法二是如果坚持用无PSRAM的经典款尽量开“Minimal SPIFFS”分区表把文件系统占用的RAM释放出来三是在联网场景下慎用Sprite毕竟一帧缓冲就要115KB留给协议栈的空间太少了。如果你打算做蓝牙App控制也建议把同屏刷新频率降低一点比如interval从50毫秒改成80毫秒让出CPU时间给协议栈。实际体验肉眼几乎分辨不出20FPS和12FPS的差异但稳定性提升很明显。写在最后这个项目做下来我最明显的感受是屏幕动画这种活儿硬件的坑远多于软件。驱动配置如果没理顺后面写再炫酷的绘图逻辑也是白搭。所以每一步我都尽量先把原理说透再给代码。尤其是那组用cos和sin计算勾玉位置的极坐标逻辑它不光是写轮眼能用的任何圆周动画、雷达扫描、时钟表盘都能复用它。你把它理解成“只是把角度换成了屏幕上的坐标”圆形屏的玩法空间一下就打开了。最后分享一个小技巧我把这个写轮眼做成了独立模块用一根Type-C线供电里面加了一个物理按键短按切换普通写轮眼、万花筒和熄灭三档状态长按调整旋转速度。这样它放在桌面上不只是个一次性Demo而是能随时把玩的互动摆件。你要是做完基础版强烈建议也加个按键试试代码逻辑没多复杂但作品完成度会高一大截。
网站建设高端定制企业官网