Baji_Rtc_Toy/main/dzbj/sprite_demo.c
Rdzleo eb96130fc9 feat(Rtc_AIavatar): 数字人透明 GIF 显示方案 PoC 完成(背景图+透明GIF叠加)
源代码变更:
- 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
2026-05-12 17:14:49 +08:00

229 lines
7.2 KiB
C
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* sprite_demo.c — Sprite Sheet PoCLVGL 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_srcLVGL 自动 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;
}