LVGL 放烟火效果不是官方某个固定 Demo,它的本质是用 LVGL 动画框架自己组装一个粒子系统:随机生成“火箭”上升,到顶点爆裂成多个彩色粒子,再伴随重力下落、渐隐消失。很多做嵌入式 UI 的工程师会把这类动效放进开机动画、节日主题页或者触屏演示程序里。真正动手之后会发现,难点通常不在“怎么画一朵烟花”,而在“粒子一多 MCU 就卡、屏幕刷新就闪、内存动不动就爆”。
这次我们来拆解这个问题。不用 GPU、不用外部图形引擎,纯 LVGL 对象和绘图接口就能实现。我会从 PC 模拟器环境搭起,给出一套可复制的 C 代码骨架,再讲怎么移植到 STM32、ESP32 这类开发板上,并重点处理性能和内存问题。如果你正准备做 LVGL 动效项目,或者想练习 lv_timer、lv_anim 和对象池,这篇文章可以直接收藏。
1. LVGL 放烟火效果核心能力速览
| 能力项 | 说明 |
|---|---|
| 项目类型 | 嵌入式 GUI 动画示例:烟火粒子效果 |
| 所属框架 | LVGL(8.x / 9.x 均可) |
| 依赖 GPU | 否,纯 CPU 绘制 |
| 核心功能 | 火箭上升、爆炸粒子、拖尾渐隐、颜色变化、多个烟花同时发射 |
| 推荐开发环境 | VS Code + LVGL 官方 PC 模拟器,或 STM32 / ESP32 + LCD 屏幕 |
| 内存要求 | 模拟器由 PC 内存决定;MCU 侧建议 RAM 256KB 以上,实际需按 LVGL 配置测试 |
| 启动方式 | 模拟器直接编译运行;开发板烧录固件运行 |
| 是否支持批量任务 | 不适用,但支持同时显示多个烟花,可扩展为定时批量发射 |
| 是否支持 API | LVGL 是 GUI 框架,不涉及 HTTP API;可把 firework_launch() 封装成内部接口供 UI 事件调用 |
| 适合场景 | 开机动画、节日主题页面、触屏演示、LVGL 动画教学 |
从这张表可以快速得出结论:这个效果不需要高端硬件,只要能跑 LVGL 的环境基本都能尝试。瓶颈不在“能不能跑”,而在“能跑多少个粒子、刷新率还能不能稳住”。
2. 适用场景与使用边界
这类动画放在 LVGL 项目里,最常见的场景有三个:
- 开机动画:设备上电后先播一段短促的烟花动效,再进入主菜单。
- 节日主题:在仪表盘、温控器、智能家居面板上叠加一个“节日模式”页面。
- 触屏交互演示:作为 LVGL 学习项目,用来验证动画接口、定时器、对象池和脏矩形刷新机制。
它不适合的场景也要说清楚。如果你需要的是 3D 烟花、复杂光影、粒子数量超过 500 的高密度特效,LVGL 并不是最优解。LVGL 面向的是 MCU 级别的 2D GUI,优势是轻量、可控、易集成,劣势是绘图性能和内存上限有限。复杂特效建议在 Linux + Wayland / Qt / 手机端实现,不要硬塞给 STM32。
还有一个使用边界值得注意:如果这个动画会出现在面向用户的商业设备中,要检查 LVGL 的许可证合规性。LVGL 采用 MIT 协议,整体使用宽松,但项目里需要保留原始版权声明。若你额外下载了烟花图片、粒子素材、音频音效,要确认素材授权范围。如果只是用代码绘制粒子颜色和轨迹,基本不存在版权引入问题。界面文案上,如果设备在公共场所循环展示烟花效果,建议只做视觉演示,不要附带“一起来放烟花”之类的交互引导,避免诱导真实燃放行为。
3. LVGL 放烟火效果环境准备与前置条件
3.1 方案选择:模拟器还是开发板
建议顺序是先跑 PC 模拟器,再移植开发板。PC 模拟器调试效率高,可以快速验证粒子逻辑、颜色搭配和动画手感;开发板是用来验证屏幕驱动、刷新策略和内存占用的。
PC 模拟器方案需要:
- VS Code 或任意主流 IDE。
- CMake 构建工具。
- 兼容 C11 或 C++ 标准的编译器,GCC 或 Clang 都可以。
- LVGL 官方 PC 模拟器工程,一般基于 SDL2 或 GTK。
开发板方案需要:
- STM32、ESP32 或其他支持 LVGL 的 MCU 开发板。
- LCD 显示屏,SPI、RGB 或 MIPI 接口均可,分辨率建议 320x240 起步。
- 对应屏幕驱动和触摸驱动源码。
- 会配置 lv_conf.h,特别是 LV_MEM_SIZE、颜色深度和刷新周期。
3.2 LVGL 版本选择
LVGL 8.x 和 9.x 的 API 有差异。本文中的核心接口,比如 lv_timer_create、lv_obj_set_pos、lv_obj_set_style_opa、lv_anim_init,在 8.x 和 9.x 中都存在,写法差异不大。如果你从 8.x 升级到 9.x,主要要检查颜色表示、默认主题和绘制掩码相关配置。更稳妥的策略是:先用你项目对应的 LVGL 官方模拟器模板,确认编译通过后,再复制本文代码骨架。
3.3 通用检查清单
| 检查项 | 要求说明 |
|---|---|
| 操作系统 | 不限,Windows / Linux / macOS 均可 |
| 编译器 | 支持 C99 以上标准 |
| LVGL 工程 | 已能成功编译并显示空白页面 |
| 屏幕分辨率 | 建议 320x240 或以上 |
| 物理内存 | MCU 建议 256KB RAM 以上 |
| Flash 空间 | 参考 LVGL 官方要求,建议 256KB 以上,具体与字体、图片资源有关 |
4. LVGL PC 模拟器搭建与基础验证
这一步的目标是得到一个可以运行 LVGL 并显示动态画面的最小工程,暂时不写任何烟花逻辑。
4.1 获取官方模拟器工程
LVGL 官方在 GitHub 提供 PC 模拟器模板,搜索 lv_port_pc_eclipse 或 lv_port_pc_vscode 即可找到。VS Code 用户可以直接用 lv_port_pc_vscode 模板,它自带 CMakeLists.txt,配置了 SDL2。
将仓库克隆到本地后,目录结构大致如下:
lv_port_pc_vscode/ ├── CMakeLists.txt ├── lv_conf.h ├── lv_drivers/ ├── lvgl/ └── main.c4.2 编译运行
在 VS Code 中打开项目,安装 CMake 插件和 C/C++ 插件,然后执行 CMake 配置和编译。命令行操作参考:
# 在项目根目录执行 cmake -B build cmake --build build运行生成的程序,应该会弹出一个 LVGL 窗口,并显示默认示例界面。如果你能自由操作鼠标,说明 LVGL 核心和模拟器驱动都已正常工作。
4.3 配置 lv_conf.h
打开 lv_conf.h,确认以下几点:
#define LV_COLOR_DEPTH 16 #define LV_MEM_SIZE (32U * 1024U) #define LV_USE_PERF_MONITOR 1这里我的建议是:LV_COLOR_DEPTH 先用 16 位,这是大多数 MCU 屏的常见色深。LV_MEM_SIZE 在模拟器里可以设大一些,比如 128KB,因为 PC 内存充足。LV_USE_PERF_MONITOR 打开后,屏幕左上角会显示 FPS 和 CPU 占用,后面验证烟花性能会非常有用。
5. 烟火效果整体设计思路
在写代码之前,先把烟火效果拆成四个阶段:
- 火箭上升:一个亮点从屏幕底部随机位置向上移动。
- 爆炸生成:到达目标高度后,生成一批粒子,初速度朝不同方向。
- 粒子运动:每个粒子受重力影响,坐标不断变化,同时颜色渐变或透明度降低。
- 渐隐消失:粒子生命值归零后被回收,供下一次爆炸复用。
整个系统的核心是多粒子管理。最忌讳的做法是每次爆炸动态创建一个新的 LVGL 对象,粒子消失时再删除。对于 MCU 来说,频繁创建销毁对象会导致内存碎片,还可能触发多次脏矩形刷新,界面掉帧明显。正确做法是对象池:提前创建固定数量的粒子对象,每个粒子通过 active 标志表示是否存活,爆炸时从池中取一个空闲粒子,生命值归零后归还。
我采用的实现方案有两种,你可以按项目情况选择:
- 方式 A:每个粒子是一个独立的 LVGL 小对象,通过 lv_obj_set_pos 移动位置。优点是方便调试,缺点是粒子数量较多时刷新开销较大。
- 方式 B:用 LVGL 的 canvas 画布,在画布上逐像素或逐点绘制粒子。优点是绘制效率高,所有粒子都只在一个对象上,刷新区域可控,缺点是代码复杂一些。
对于 60 到 120 个粒子的烟火效果,方式 A 在模拟器上完全够用,在 RAM 较大的 MCU 上也能跑。方式 B 更适合粒子数量达到 200 以上、屏幕分辨率偏低、刷新压力大的场景。本文以方式 A 为主,末尾讲方式 B 的扩展思路。
5.1 粒子结构体
#include "lvgl.h" #include <stdlib.h> #include <math.h> #define MAX_DOTS 120 // 最大粒子数 #define FIREWORK_LIFE 120 // 粒子生命值 typedef struct { lv_obj_t *obj; // 粒子对象,一个实心小圆点 int16_t x; int16_t y; int16_t vx; // 水平速度 int16_t vy; // 垂直速度 uint8_t life; // 剩余生命 uint8_t active; // 是否在运行动画中 uint8_t color_r; uint8_t color_g; uint8_t color_b; } firework_dot_t; static firework_dot_t s_dots[MAX_DOTS]; static lv_obj_t *s_parent;粒子结构的核心是三个状态:坐标、速度、生命。所有粒子一开始都处于未激活状态。
5.2 初始化粒子池
void firework_init(lv_obj_t *parent) { s_parent = parent; for (int i = 0; i < MAX_DOTS; i++) { s_dots[i].obj = NULL; s_dots[i].active = 0; } }使用对象池时,最好在初始化阶段就创建对象,或者预留创建逻辑。为了避免资源浪费,也可以等到第一次爆炸时再创建,后续全部复用。下面的爆炸函数会处理这种情况。
5.3 发射一枚烟花
所有粒子从同一个爆炸点出发,速度方向在 360 度范围内随机分布,垂直初速度略向下,模拟重力效果。
static void dot_init(int index, uint16_t cx, uint16_t cy, lv_color_t color) { firework_dot_t *dot = &s_dots[index]; if (dot->obj == NULL) { dot->obj = lv_obj_create(s_parent); lv_obj_set_size(dot->obj, 4, 4); lv_obj_remove_style_all(dot->obj); lv_obj_set_style_bg_color(dot->obj, color, LV_STATE_DEFAULT); lv_obj_set_style_bg_opa(dot->obj, LV_OPA_COVER, LV_STATE_DEFAULT); lv_obj_set_style_radius(dot->obj, LV_RADIUS_CIRCLE, LV_STATE_DEFAULT); } int angle = lv_rand(0, 360); int speed = lv_rand(30, 80); dot->x = cx; dot->y = cy; dot->vx = (int16_t)(speed * cos(angle * 3.14159 / 180.0)); dot->vy = (int16_t)(speed * sin(angle * 3.14159 / 180.0) - 20); dot->life = FIREWORK_LIFE; dot->active = 1; lv_obj_set_pos(dot->obj, dot->x, dot->y); } void firework_launch(uint16_t cx, uint16_t cy, lv_color_t color) { int spawned = 0; for (int i = 0; i < MAX_DOTS; i++) { if (!s_dots[i].active) { dot_init(i, cx, cy, color); spawned++; if (spawned >= 30) { break; } } } }一枚烟花一般产生 30 到 50 个粒子。这个代码里一次最多取 30 个空闲粒子,避免一次性占满对象池。
5.4 用定时器驱动粒子运动
LVGL 的核心运行依赖于 lv_timer,每隔一段时间执行一次回调。烟火动画也放在定时器里统一更新。
static void firework_timer_cb(lv_timer_t *timer) { uint16_t cx = lv_rand(20, 300); uint16_t cy = lv_rand(20, 180); lv_color_t color = lv_color_make(lv_rand(120, 255), lv_rand(80, 200), lv_rand(80, 220)); firework_launch(cx, cy, color); for (int i = 0; i < MAX_DOTS; i++) { firework_dot_t *dot = &s_dots[i]; if (!dot->active) { continue; } dot->vx = dot->vx * 96 / 100; dot->vy = dot->vy * 96 / 100 + 2; dot->x += dot->vx / 10; dot->y += dot->vy / 10; dot->life--; // 透明度随生命值衰减 uint8_t opa = (dot->life * LV_OPA_COVER) / FIREWORK_LIFE; lv_obj_set_style_bg_opa(dot->obj, opa, LV_STATE_DEFAULT); lv_obj_set_pos(dot->obj, dot->x, dot->y); if (dot->life <= 0) { dot->active = 0; } } } void firework_start(void) { lv_timer_create(firework_timer_cb, 30, NULL); }这段逻辑做了三件关键的事:
- 每个周期随机生成一个新烟花,保证屏幕上有连续效果。
- 已激活粒子按照速度更新坐标,同时施加简单重力。
- 粒子生命值归零后标记为未激活,等待下次复用。
定时器周期设为 30ms,也就是约 33 FPS。如果屏幕分辨率高或者粒子数量多,可以把周期改成 40ms,降低刷新频率。
6. 功能测试与效果验证
6.1 基础生成能力测试
在 main.c 里调用 firework_init 和 firework_start,然后运行模拟器。
void lv_example_firework(void) { lv_obj_t *scr = lv_scr_act(); firework_init(scr); firework_start(); }预期效果是:屏幕不同位置不断出现爆炸粒子,粒子向外飞散并逐渐变透明。判断成功的标准有三个:
- 粒子不会闪全屏,只在爆炸区域附近出现局部刷新。
- 粒子数量稳定,不会随着时间推移无限增长。
- 界面鼠标操作仍响应,画面不被动画完全占住。
如果粒子一多就白屏闪烁,说明刷新策略有问题,后面排查章节会讲。
6.2 自定义参数测试
把 firework_launch 的爆炸点固定为屏幕中间,颜色固定为红色,并提高粒子数为 50,验证单个烟花的表现:
void firework_test_single(void) { lv_color_t color = lv_color_make(255, 40, 40); firework_launch(160, 120, color); }如果是 320x240 的分辨率,爆炸点选在 (160, 120),粒子最好控制在 40 到 60 个。数量超过 80 后,MCU 版会增加刷新负担。
6.3 连续与齐射效果测试
可以把多个 firework_launch 一起调用,模拟齐射。只要对象池还有空闲粒子,这种方式能直接撑起更丰富的高潮场景:
firework_launch(80, 100, lv_color_make(255, 180, 0)); firework_launch(240, 100, lv_color_make(0, 180, 255)); firework_launch(160, 60, lv_color_make(255, 80, 80));预期结果是三个爆炸点同时出现三团不同颜色的粒子。如果画面出现明显卡顿或闪烁,优先减小每个烟花的粒子数。
6.4 显存与内存观察
运行模拟器时打开 LV_USE_PERF_MONITOR,屏幕左上角会显示帧率。粒子效果运行期间,帧率稳定在 25 FPS 以上属于可用状态,30 FPS 以上比较理想。如果帧率低于 20,说明粒子刷新开销过大,需要做优化。
MCU 板卡上,观察内存更直接的方法是串口打印 LVGL 内存状态:
lv_mem_monitor_t mon; lv_mem_monitor(&mon); LV_LOG_USER("used: %d, free: %d", (int)mon.used_size, (int)mon.free_size);放到 firework_timer_cb 里周期性打印一次,观察粒子运行期间空闲内存是否有明显下降趋势。如果空闲内存不断减少,说明对象池或 LVGL 对象处理存在问题。
7. 移植到 STM32 / ESP32:资源占用与性能观察
模拟器跑通后,并不代表开发板上也能流畅运行。MCU 的资源限制是真正考验代码设计的地方。
7.1 内存配置建议
LVGL 在 MCU 上需要两块主要内存:
- LVGL 堆内存,由 LV_MEM_SIZE 控制。
- 帧缓冲,可以是单缓冲、双缓冲或部分缓冲。
对于 320x240x16bit 的屏幕,一帧裸数据约为 320 x 240 x 2 = 150KB。如果 MCU RAM 只有 256KB,双缓冲几乎不可能,实际项目更多使用“单缓冲 + 脏矩形刷新”配合 LVGL 的局部重绘机制。更稳妥的做法是把 LV_MEM_SIZE 保持在 32KB 到 64KB,然后根据实际内存占用调整。
建议先按以下配置起步:
#define LV_MEM_SIZE (48U * 1024U) #define LV_COLOR_DEPTH 16 #define LV_USE_PERF_MONITOR 1粒子对象不直接占用 LV_MEM_SIZE 的大头,因为对象池里的 lv_obj 都是预创建的常量数量。每个粒子对象约消耗几十到上百字节,120 个粒子大概在 8KB 到 15KB 之间,具体取决于 LVGL 版本和样式配置。这个量级在 256KB RAM 的 MCU 上是可以接受的。
7.2 CPU 占用与刷新策略
LVGL 是 CPU 绘制的 GUI,CPU 占用主要取决于每一帧需要重绘的像素数量。粒子数量越多、每个粒子对象越大,脏矩形区域越大,CPU 绘图压力越高。
最有效的优化是让粒子尽量小。代码里设置粒子大小为 4x4 已经比较合理,如果屏幕分辨率较小,可以改成 2x2。另一个手段是关闭不必要的样式,比如阴影、渐变、抗锯齿。在 lv_obj_create 后调用 lv_obj_remove_style_all 是必要的。
还需要注意 STM32 这类 MCU 上的 DMA 传输。大部分 SPI 屏驱动都支持 DMA 发送帧缓冲,开启后能显著降低 CPU 刷新压力。具体配置看 LCD 驱动代码,通常是在写像素数据时改为 DMA 发送。
7.3 掉帧和闪烁排查
在开发板上运行时,如果出现闪烁,首先检查刷新缓冲区是否只有一行或一个很小的区域。LVGL 局部刷新依赖于脏矩形,如果每个粒子对象都跨越屏幕较大区域,脏矩形会合并成很大的矩形块,导致刷新面积快速膨胀。粒子分布在屏幕四周时,多个脏矩形可能被合并为整个屏幕,这是闪烁的最常见原因。
解决方案是限制粒子活动区域。不要把爆炸点设置太分散,尽量让每枚烟花的粒子集中在屏幕 1/4 区域内,这样脏矩形面积更小。
8. LVGL 放烟火效果常见问题与排查方法
| 问题现象 | 可能原因 | 排查方式 | 解决方案 |
|---|---|---|---|
| 模拟器编译失败 | 缺少 SDL2 或 CMake 配置错误 | 查看 CMake 输出日志 | 安装 SDL2 开发库,检查 CMakeCache 路径 |
| 粒子对象显示不出来 | 未调用 lv_obj_remove_style_all,默认样式背景覆盖 | 打印对象位置和背景颜色 | 补上默认样式清理并设置背景色和圆角 |
| 粒子不会消失 | life 归零后未置 active=0 | 在回调里打断点查看 life 值 | 确保生命值递减并更新 active 状态 |
| 画面闪烁严重 | 脏矩形范围过大 | 打开 LV_USE_PERF_MONITOR 观察刷新区域 | 缩小粒子对象 4x4 改为 2x2,控制爆炸区范围 |
| MCU 上内存不足 | LV_MEM_SIZE 偏大或粒子对象过多 | 打印 lv_mem_monitor 日志 | 减少 MAX_DOTS,降低 LV_MEM_SIZE,减少样式 |
| 粒子颜色不对 | LV_COLOR_DEPTH 配置和屏幕色深不匹配 | 对比屏幕驱动文档 | 统一 16bit RGB565 或 32bit ARGB8888 |
| 动画卡顿 | 定时器周期过短或刷新面积过大 | 检查帧率 | 定时器周期从 30ms 调到 40ms,减少粒子数 |
| 开机动画后 UI 响应慢 | 定时器优先级过高或持续刷屏 | 检查 lv_timer 周期性调用 | 动画结束后用 lv_timer_del 删除定时器 |
这几个问题覆盖了 LVGL 烟火效果最常见的 80% 场景。把 LV_USE_PERF_MONITOR 开启,查看帧率变化,基本上能定位到是绘制量问题还是内存问题。
9. 最佳实践与使用建议
9.1 用对象池管理粒子
永远不要动态创建和删除粒子对象。即使 LVGL 本身有自研内存管理,频繁的 lv_obj_create 和 lv_obj_del 也会带来内存碎片。固定大小的对象池可以保证内存布局稳定,粒子数量可控,刷新性能可预测。
9.2 先小参数验证,再逐步放大
第一次运行建议这样配:
#define MAX_DOTS 60 #define FIREWORK_LIFE 80 // 定时器周期 40ms跑通后,再慢慢调大 MAX_DOTS 和 FIREWORK_LIFE。这样可以快速定位到底是内存原因还是 CPU 刷新原因。
9.3 动画结束后要登出资源
如果烟火效果只是开机动画,播完 5 到 10 秒后应该停止发射新烟花,并让当前粒子自然消失。定时器可以用 lv_timer_del 删除。不要让一个动画定时器长期占用 CPU 资源。
static lv_timer_t *s_firework_timer = NULL; void firework_stop(void) { if (s_firework_timer) { lv_timer_del(s_firework_timer); s_firework_timer = NULL; } }9.4 封装修饰接口
把 firework_launch 作为对外接口,设计成方便业务模块调用的形式:
void ui_firework_show(uint16_t cx, uint16_t cy, lv_color_t color); void ui_firework_hide(void);这样开机逻辑、点击事件、节日模式切换都只需要调用接口,不需要关心内部粒子实现。
9.5 版权与合规提醒
如果只使用自绘粒子、LVGL 自带 API 和默认文档,不涉及外部素材,版权风险很低。如果烟火效果使用到下载的火花素材、爆炸音效或第三方字体,必须确认素材授权。机械、医疗、车载等关键 UI 中,应避免使用过于频繁的闪烁动效,防止对特殊人群造成不适或分散注意力。
10. 总结与下一步
LVGL 放烟火效果最值得尝试的点,是它让你第一次意识到 MCU 上的粒子动画并没有那么高不可攀。核心逻辑只有三个部分:粒子结构、运动更新、定时器驱动。再把对象池应用进来,就同时解决了内存和刷新性能两大问题。
最先应该验证的功能,就是让一个固定位置的烟花跑通,观察粒子的运动轨迹和透明度渐变。这个只要模拟器能工作,十分钟内就能看到效果。
最容易踩的坑有两个:一是粒子对象动态创建删除导致卡顿和内存碎片;二是粒子分布过散导致脏矩形合并、屏幕闪烁。文章里的对象池和限制爆炸区域两种方式,可以直接避开这两个坑。
后续扩展方向可以考虑:
- 给每个烟花增加拖尾效果,在粒子后面画一条渐变线段。
- 把粒子绘制从独立对象改成 LVGL canvas 画布,提高粒子数量上限。
- 接入触摸屏,点击位置触发烟花,测试 LVGL 事件系统与动画的配合。
- 增加音效模块,在爆炸瞬间播放短音频,不过需要注意音频资源授权和 MCU Flash 空间。
想清楚这些,一个能在 STM32 或 ESP32 上稳定运行的 LVGL 烟火动画就能落地了。建议先把这个项目当作 LVGL 动画和内存优化的练手工程,跑通后再考虑融入正式产品。