源代码变更: - main/dzbj/bg_gif_demo.c/h: 方案 C 最终实现 - JPG 背景图(lv_img) + 透明 GIF(lv_gif) 叠加 - main/dzbj/dual_gif_demo.c/h: 方案 B 中间产物 - 双 GIF 循环切换 - main/dzbj/sprite_demo.c/h: 方案 A 已弃用 - DMA 直写 GRAM 与 LVGL 争抢 LCD IO 失败 - main/dzbj/ai_chat_ui.c: 集成 USE_BG_GIF_POC 开关,加载背景图+透明 GIF - main/dzbj/lcd.c: panel_handle 移除 static,便于其他模块访问 - main/CMakeLists.txt: 新增 3 个 dzbj 模块编译 资源新增: - spiffs_image/Background_360x360.jpg: 设备背景图(20KB) - spiffs_image/hiyori_m05.gif: Cubism Editor 直接导出的透明 GIF(2.3MB) - docs/Rtc_AIavatar/: Live2D 模型(Hiyori/Haru) + 32 段 Haru GIF + 方案文档第18章 PoC 实战记录 - tools/sprite_poc/: Python GIF→RGB565 转换脚本 踩坑要点(详见 docs/Rtc_AIavatar 第18章): - PIL Image.quantize() 会破坏 RGBA 透明度,必须改用 gifsicle - PIL 保存动画 GIF 仅第1帧有透明,后续帧不透明 - LVGL gifdec 按帧读取 - Cubism Editor 直接导出 GIF 才能逐帧保留透明信息(FREE 版限制部分模型) - gifsicle --lossy 会严重锯齿化,去掉只保留 --colors 256 + -O3 即可 - 裁剪居中需用全帧 bbox 不能只看第1帧(Live2D 角色每帧位置有偏移) - LVGL 默认不支持 PNG,背景图用 JPG + esp_jpeg 解码到 RGB565 buffer - 透明 GIF 显示黑色背景: gifdec.c canvas 初始化 alpha 须改为 0x00
229 lines
7.2 KiB
C
229 lines
7.2 KiB
C
/**
|
||
* sprite_demo.c — Sprite Sheet PoC(LVGL lv_img 方案)
|
||
*
|
||
* 数据流:SPIFFS .bin → PSRAM → 构造 lv_img_dsc_t 数组 → LVGL lv_img 控件 → LVGL 自动 flush
|
||
*
|
||
* 设计要点:
|
||
* - sprite 帧数据预先转好 RGB565,加载到 PSRAM 后零拷贝
|
||
* - 用 LVGL lv_img 控件显示,完全在 LVGL 控制下,不绕过 LVGL
|
||
* - lv_timer 定时切换 lv_img_set_src,LVGL 自动 invalidate 和 flush
|
||
* - 与 LVGL 其他控件(字幕/电池等)共存,无 SPI 队列冲突
|
||
*/
|
||
#include "sprite_demo.h"
|
||
#include "esp_log.h"
|
||
#include "esp_heap_caps.h"
|
||
#include "esp_lvgl_port.h"
|
||
#include "esp_spiffs.h"
|
||
#include "lvgl.h"
|
||
#include <stdio.h>
|
||
#include <string.h>
|
||
#include <stdint.h>
|
||
#include <stdlib.h>
|
||
|
||
static const char *TAG = "SPRITE_DEMO";
|
||
|
||
// 文件格式定义(与 Python 脚本一致,总计 64 字节)
|
||
typedef struct __attribute__((packed)) {
|
||
char magic[8];
|
||
uint32_t version;
|
||
uint16_t frame_width;
|
||
uint16_t frame_height;
|
||
uint8_t pixel_format;
|
||
uint8_t emotion_count;
|
||
uint8_t motion_count;
|
||
uint8_t mouth_levels;
|
||
uint32_t emotion_table_offset;
|
||
uint32_t motion_table_offset;
|
||
uint32_t frame_data_offset;
|
||
uint32_t total_frames;
|
||
uint32_t total_size;
|
||
uint8_t checksum[4];
|
||
uint8_t reserved[20];
|
||
} sprite_header_t;
|
||
_Static_assert(sizeof(sprite_header_t) == 64, "sprite_header_t must be 64 bytes");
|
||
|
||
typedef struct __attribute__((packed)) {
|
||
char name[8];
|
||
uint16_t frame_count;
|
||
uint16_t fps;
|
||
uint32_t first_frame_idx;
|
||
uint32_t flags;
|
||
} sprite_entry_t;
|
||
|
||
// 运行时状态
|
||
static sprite_header_t g_header;
|
||
static sprite_entry_t g_entry;
|
||
static uint8_t *g_frames = NULL;
|
||
static lv_img_dsc_t *g_frame_dscs = NULL; // 每帧一个 lv_img_dsc_t
|
||
static uint16_t g_current_frame = 0;
|
||
static lv_obj_t *g_sprite_img = NULL; // LVGL 图像控件
|
||
static lv_timer_t *g_play_timer = NULL;
|
||
static bool g_playing = false;
|
||
|
||
// LVGL 定时器回调(在 LVGL 任务中执行)
|
||
static void play_timer_cb(lv_timer_t *timer) {
|
||
if (!g_playing || !g_frame_dscs || !g_sprite_img) return;
|
||
|
||
g_current_frame = (g_current_frame + 1) % g_entry.frame_count;
|
||
lv_img_set_src(g_sprite_img, &g_frame_dscs[g_current_frame]);
|
||
}
|
||
|
||
esp_err_t sprite_demo_start(const char *path) {
|
||
if (g_playing) {
|
||
ESP_LOGW(TAG, "已在播放,先 stop 再 start");
|
||
return ESP_ERR_INVALID_STATE;
|
||
}
|
||
|
||
// 检查 SPIFFS 是否已挂载,未挂载则自动挂载
|
||
size_t total = 0, used = 0;
|
||
if (esp_spiffs_info("storage", &total, &used) != ESP_OK) {
|
||
ESP_LOGI(TAG, "SPIFFS 未挂载,自动挂载 storage 分区...");
|
||
esp_vfs_spiffs_conf_t conf = {
|
||
.base_path = "/spiflash",
|
||
.partition_label = "storage",
|
||
.max_files = 5,
|
||
.format_if_mount_failed = false,
|
||
};
|
||
esp_err_t err = esp_vfs_spiffs_register(&conf);
|
||
if (err != ESP_OK) {
|
||
ESP_LOGE(TAG, "SPIFFS 挂载失败: %s", esp_err_to_name(err));
|
||
return err;
|
||
}
|
||
}
|
||
|
||
FILE *f = fopen(path, "rb");
|
||
if (!f) {
|
||
ESP_LOGE(TAG, "打开文件失败: %s", path);
|
||
return ESP_FAIL;
|
||
}
|
||
|
||
// 1. 读文件头
|
||
if (fread(&g_header, 1, sizeof(sprite_header_t), f) != sizeof(sprite_header_t)) {
|
||
ESP_LOGE(TAG, "读文件头失败");
|
||
fclose(f);
|
||
return ESP_FAIL;
|
||
}
|
||
if (memcmp(g_header.magic, "SPRITES", 7) != 0) {
|
||
ESP_LOGE(TAG, "magic 不匹配");
|
||
fclose(f);
|
||
return ESP_ERR_INVALID_ARG;
|
||
}
|
||
ESP_LOGI(TAG, "Sprite Pack: %dx%d, 总帧数=%lu",
|
||
g_header.frame_width, g_header.frame_height, g_header.total_frames);
|
||
|
||
// 2. 读情绪索引
|
||
fseek(f, g_header.emotion_table_offset, SEEK_SET);
|
||
fread(&g_entry, 1, sizeof(sprite_entry_t), f);
|
||
char name[9] = {0};
|
||
memcpy(name, g_entry.name, 8);
|
||
ESP_LOGI(TAG, "Entry: \"%s\", %d 帧 @ %d FPS", name, g_entry.frame_count, g_entry.fps);
|
||
|
||
// 3. 加载帧数据到 PSRAM
|
||
uint32_t frame_size = g_header.frame_width * g_header.frame_height * 2;
|
||
size_t total_data = frame_size * g_entry.frame_count;
|
||
g_frames = heap_caps_malloc(total_data, MALLOC_CAP_SPIRAM);
|
||
if (!g_frames) {
|
||
ESP_LOGE(TAG, "PSRAM 分配失败: %d 字节", (int)total_data);
|
||
fclose(f);
|
||
return ESP_ERR_NO_MEM;
|
||
}
|
||
fseek(f, g_header.frame_data_offset, SEEK_SET);
|
||
fread(g_frames, 1, total_data, f);
|
||
fclose(f);
|
||
ESP_LOGI(TAG, "已加载 %.1f KB 到 PSRAM", total_data / 1024.0);
|
||
|
||
// 4. 为每帧构造 lv_img_dsc_t(共享 PSRAM 数据,零拷贝)
|
||
g_frame_dscs = calloc(g_entry.frame_count, sizeof(lv_img_dsc_t));
|
||
if (!g_frame_dscs) {
|
||
ESP_LOGE(TAG, "frame_dscs 分配失败");
|
||
heap_caps_free(g_frames);
|
||
g_frames = NULL;
|
||
return ESP_ERR_NO_MEM;
|
||
}
|
||
for (int i = 0; i < g_entry.frame_count; i++) {
|
||
g_frame_dscs[i].header.cf = LV_IMG_CF_TRUE_COLOR;
|
||
g_frame_dscs[i].header.always_zero = 0;
|
||
g_frame_dscs[i].header.reserved = 0;
|
||
g_frame_dscs[i].header.w = g_header.frame_width;
|
||
g_frame_dscs[i].header.h = g_header.frame_height;
|
||
g_frame_dscs[i].data_size = frame_size;
|
||
g_frame_dscs[i].data = g_frames + i * frame_size;
|
||
}
|
||
|
||
// 5. 在当前活动屏幕上创建 lv_img 控件并启动定时器
|
||
if (!lvgl_port_lock(200)) {
|
||
ESP_LOGE(TAG, "lvgl_port_lock 失败");
|
||
free(g_frame_dscs);
|
||
g_frame_dscs = NULL;
|
||
heap_caps_free(g_frames);
|
||
g_frames = NULL;
|
||
return ESP_FAIL;
|
||
}
|
||
|
||
g_sprite_img = lv_img_create(lv_scr_act());
|
||
lv_img_set_src(g_sprite_img, &g_frame_dscs[0]);
|
||
lv_obj_align(g_sprite_img, LV_ALIGN_CENTER, 0, 0);
|
||
|
||
g_current_frame = 0;
|
||
g_playing = true;
|
||
uint32_t interval_ms = 1000 / g_entry.fps;
|
||
g_play_timer = lv_timer_create(play_timer_cb, interval_ms, NULL);
|
||
|
||
lvgl_port_unlock();
|
||
|
||
ESP_LOGI(TAG, "✓ 开始播放 @ %d FPS (interval=%lu ms, %dx%d)",
|
||
g_entry.fps, (unsigned long)interval_ms,
|
||
g_header.frame_width, g_header.frame_height);
|
||
return ESP_OK;
|
||
}
|
||
|
||
void sprite_demo_pause(void) {
|
||
if (g_play_timer && g_playing) {
|
||
if (lvgl_port_lock(100)) {
|
||
lv_timer_pause(g_play_timer);
|
||
lvgl_port_unlock();
|
||
}
|
||
g_playing = false;
|
||
ESP_LOGI(TAG, "暂停播放");
|
||
}
|
||
}
|
||
|
||
void sprite_demo_resume(void) {
|
||
if (g_play_timer && !g_playing && g_frames) {
|
||
if (lvgl_port_lock(100)) {
|
||
lv_timer_resume(g_play_timer);
|
||
lvgl_port_unlock();
|
||
}
|
||
g_playing = true;
|
||
ESP_LOGI(TAG, "恢复播放");
|
||
}
|
||
}
|
||
|
||
void sprite_demo_stop(void) {
|
||
if (lvgl_port_lock(200)) {
|
||
if (g_play_timer) {
|
||
lv_timer_del(g_play_timer);
|
||
g_play_timer = NULL;
|
||
}
|
||
if (g_sprite_img) {
|
||
lv_obj_del(g_sprite_img);
|
||
g_sprite_img = NULL;
|
||
}
|
||
lvgl_port_unlock();
|
||
}
|
||
if (g_frame_dscs) {
|
||
free(g_frame_dscs);
|
||
g_frame_dscs = NULL;
|
||
}
|
||
if (g_frames) {
|
||
heap_caps_free(g_frames);
|
||
g_frames = NULL;
|
||
}
|
||
g_playing = false;
|
||
ESP_LOGI(TAG, "停止播放并释放 PSRAM");
|
||
}
|
||
|
||
bool sprite_demo_is_playing(void) {
|
||
return g_playing;
|
||
}
|