Compare commits

..
30 Commits
Author SHA1 Message Date
HP f6f2a74e1d over 2026-07-17 14:10:29 +08:00
HP ad63d5640b 111 2026-07-10 14:22:29 +08:00
HP 45f8aa2b58 111 2026-07-10 10:11:04 +08:00
HP 1a194da7ec 很多修改 2026-07-08 15:55:13 +08:00
HP d95ccc0c99 完善TomGame的全部内容并测试,模型流程改动到C++。 2026-07-06 19:04:21 +08:00
basil 36cbaf18c4 添加LightGame报告,规范接口命名 2026-07-04 19:07:53 +08:00
basil 10872fde82 重构平台条件编译,剥离 LightGame 调试内容到 Debug 配置
- 把 USE_FRAMEBUFFER 替换为 TARGET_IMX / TARGET_PC 双正向互斥宏,由 CMake 选项 TARGET_IMX 推导,代码层只用 #ifdef 正向判断
- 新增 IMX6U_DEBUG 宏,通过 $<$<CONFIG:Debug>:...> 只在 Debug 构建中注入
- LightGame 关卡编辑器(LevelEditor + ImGui)改为仅 PC + Debug 编入,Release 不参与编译
- LightGame draw_debug、debug_mode_、--debug 命令行、[INFO] 启动日志全部用 IMX6U_DEBUG 门控,Release 二进制中完全消失
- [WARN] 硬件初始化失败提示保留在 Release,作为现场诊断信息
- README、APP_AND_CORE_ARCHITECTURE、Game README 同步新宏命名,并新增「构建类型与调试开关」小节
2026-06-16 13:46:12 +08:00
SepComet 334c9ee96f 添加补充光敏传感器驱动 2026-06-15 20:20:49 +08:00
HP e0946fbf36 完成语音识别功能(win测试,arm未知) 2026-06-14 20:31:04 +08:00
SepComet de85a8d41d delete SampleScene.unity 2026-06-14 09:42:56 +08:00
SepComet 786170f40a LightGame 完成 Windows 2026-06-14 09:42:27 +08:00
SepComet 254a8ce5c8 修复 LightGame 玩家地面抖动、动画切换异常并完善 spike/终点视觉
## 物理与动画(核心修复)

- PlayerController::update:地面状态下跳过 apply_gravity。之前每帧重力累入 velocity.y,sub_pixel_y_ 会缓慢累到 ≥1000 像素阈值,导致玩家下沉 1px 后被 collide_tilemap 推回,每隔几帧抖动一次。这是"动画机切不回 idle"的真正根因 —— 单帧 grounded=false 让状态切到 Falling,下一帧又回 Idle,视觉表现为 idle/fall 帧间歇错位。

- Physics2D::collide_tilemap:发生轴向推回时清掉对应 sub_pixel_x_/y_,防止小数残留在下一帧继续推进位置。
- Physics2D::is_grounded:feet 探测窗口(max.y..max.y+2)跨越的所有 tile 行都要扫描,而非只查 feet.min.y/ts 那一行。原写法在边界帧(feet.max.y 临时为 31n+0)会错把上一行空 tile 当判定行返回 false,触发上述的单帧 Falling 抖动。
- PlayerController::update:grounded && velocity.y >= 0 时无条件清零 velocity.y(不再要求 !was_grounded_),避免重力残留导致连续下沉。

## Spike 行为

- Physics2D::is_tile_solid:spike(tile id 4) 改为非实体,玩家可踩入触发死亡。原先 spike 与普通地块都被当 solid,玩家停在 spike 顶部不会死。
- PlayerController::check_death:spike 击杀区域收紧到 tile 底部 14px(视觉高度),不再用整 32px tile 误判。

## 关卡视觉(出生点 / 检查点 / 终点)

- LevelLoader:玩家出生点附近生成 flag 装饰对象;Checkpoint 触发器初始显示 checkpoint 精灵,被激活后由 LightGameApp 在 check_triggers 中替换为 flag 精灵;LevelComplete 触发器使用 checkpoint 精灵作为终点。
- LevelLoader 暴露 flag_sprite() 静态访问器,供 LightGameApp 触发激活时复用同一份 sprite 引用。

## 资源与配套

- 重新打包 tile_atlas(重新生成 generated/tile_atlas.h),更新 assets/tile/checkpoint.png、flag.png 的视觉素材。
- Level1Data、LevelEditor、LevelRenderer、GameObject、tests 跟随接口调整同步更新;game_engine_tests 重新编译通过。
2026-06-13 14:54:29 +08:00
basil 05d7d9783e 添加 LightGame 关卡编辑器并修复死亡扣命、光感门阈值与渲染问题
- 新增 LevelEditor:ImGui 面板支持 tile 绘制、对象放置、选择、擦除、网格/碰撞体可视化、关卡导出为头文件
- 修复 LevelRenderer::draw 中 draw_tilemap 错误传入负 screen 坐标导致摄像机移动后 tile 消失
- 修复 Preview Light 滑块用 reinterpret_cast 将 uint16_t 强转为 int* 写入 4 字节覆盖相邻 bool 成员
- 修复编辑器放置光感门缺少 light_threshold 默认值导致不响应亮度变化
- 统一 LightPlatform[3192,4095] ShadowPlatform[0,1023] Door[1023,3192] 阈值到关卡数据与编辑器默认值
- 修复 SDL_StopTextInput 导致 ImGui InputText 无法输入及键盘状态异常
- 修复死亡状态每帧扣命导致一次死亡直接 GameOver
- 集成 ImGui (SDL2 Renderer 后端) 到构建系统
2026-06-11 19:30:04 +08:00
basil d49aef8c0f 调整 LightGame 平台碰撞与门尺寸,修复测试编译
- LightPlatform / ShadowPlatform:移除单向板碰撞逻辑,改为完整 tile 碰撞体
- Door:碰撞箱从 32x64 改为 32x32,与单 tile 素材匹配
- 修复 game_engine_tests 编译:补全 LevelData 初始化字段,增加缺失的 include path
2026-06-11 15:27:57 +08:00
basil 48fd7818d7 补充素材 2026-06-11 15:07:32 +08:00
basil 46c76ec7fc 修复 LightGame 多项 gameplay bug 并完善光敏渲染体系
- 出生点校验:load_level() 加载后 assert 验证 spawn point 是否站在固体地面且不穿墙
- Checkpoint 系统:LevelLoader 为 Checkpoint Trigger 注册 checkpoint 位置,check_triggers() 正确激活 checkpoint,respawn() 使用最后激活的 checkpoint
- Death 触发器:PlayerController 新增 kill(),TriggerAction::Death 正确触发死亡
- Tilemap 光敏 tile:新增 TileLightRule 与 mutable tile buffer,LightEffectSystem::update_tilemap() 按光照条件动态显示/隐藏 tilemap 中的 tile
- GameObject 渲染:LevelLoader 为所有对象分配 tile atlas sprite(金币、门、陷阱、平台、checkpoint 等不再隐形)
- 光敏对象双状态:LightPlatform / ShadowPlatform / Door 均支持 on/off(或 open/closed)两种 sprite 切换,should_render() 始终返回 true,视觉状态由 sprite 承担
- 渲染层级:LevelRenderer 最后绘制玩家,确保玩家始终在最上层
2026-06-11 11:33:36 +08:00
basil acf162d1b9 修复 LightGame Windows 输入问题并完善手动亮度控制
- 重构 PlayerController 输入:移除 IButtonInput 依赖,纯使用 IKeyboardState 与 IPointerInput;触摸屏改为上半屏跳跃、左右 1/3 分区分左右移动
- GameStateManager 增加 ESC 键暂停/恢复支持
- 将键盘亮度控制从 SdlPhotoSensor 平台层上提到 LightGameApp 游戏层(W/S 键),并引入 has_manual_override_ 标志防止 photo sensor 默认值覆盖手动调整
- SDLDisplay 初始化时调用 SDL_StopTextInput(),缓解 Windows 中文输入法在按字母键后拦截方向键/空格的问题
- IKeyboardState.h 补充 KEY_S、KEY_ESC、KEY_RETURN 常量
2026-06-11 10:41:53 +08:00
basil fda8b120d3 为 LightGame 提供素材转换工具和转换后的头文件 2026-06-11 09:28:20 +08:00
basil e00fc1799d 重构键盘输入为 IKeyboardState 接口,消除 App 层对 SDL 的直接依赖
重构键盘输入为 IKeyboardState 接口,消除 App 层对 SDL 的直接依赖

- 新增 IKeyboardState 抽象接口及 SdlKeyboardState/EvdevKeyboardState 实现
- PlayerController 改用 IKeyboardState 替代直接调用 SDL_GetKeyboardState
- 移除 Camera2D.h 中对 Core 私有头文件 Camera.h 的引用
- 将 Timer.h 从 Core/Core 移至 Core/Platform,符合架构边界规范
- 键盘输入优先级调整为高于指针输入
2026-06-10 15:22:30 +08:00
HP fb216de107 修复windows平台下音频播放问题。 2026-06-09 18:17:52 +08:00
basil aecd5a46ed 搭建 LightGame 游戏框架 2026-06-09 12:25:01 +08:00
basil 189072ba4e 更新文档 2026-06-09 10:39:10 +08:00
basil 0fdcb6b8ff stop tracking build-check directory 2026-06-09 10:31:30 +08:00
basil feb088a854 统一 Image 和 Sprite 语义到目前的 Image/Sprite 结构体而非之前的类定义 2026-06-09 10:28:29 +08:00
basil 56eec9e9d2 收口颜色格式,删除原来的 RGBA8888 的存储和调用 2026-06-09 09:00:43 +08:00
basil 23a5b50aec 收口字体渲染路径以降低 atlas 体积并直接输出 RGB565
将位图字体从 RGBA8888 atlas 改为 1-bit mask,避免继续为字体存储无效颜色信息。
生成端现在按 row-major、MSB-first 将 8 个像素打包进 1 个 uint8_t;运行时由
draw_text 提供目标颜色,并在 DrawContext 中直接把 bit=1 的像素写入 RGB565 framebuffer。同步补充了回归测试与文档,确保颜色替换、背景填充和裁剪语义保持稳定。

Constraint: 本次只收口字体路径,不改 sprite/tilemap 资源格式
Constraint: 运行时保持纯二值绘制语义,不引入 alpha blending 或抗锯齿
Rejected: 继续兼容 RGBA8888 字体输入 | 会保留双路径复杂度且无实际收益
Confidence: high
Scope-risk: moderate
Reversibility: clean
Directive: 后续字体资源必须继续使用 row-major + MSB-first 的 1-bit mask;修改位序前先同步生成脚本、运行时读取和测试
Tested: cmake --build build-win --config Release
Tested: build-win/Release/render_pipeline_tests.exe
Tested: ctest -C Release --output-on-failure
Tested: font_atlas_mask 字节数校验(2808 == ceil(208*108/8))
Not-tested: Demo/设备上的人工目视文字显示效果
2026-06-09 08:20:38 +08:00
basil 5ac61dca0e 统一 2D 渲染链路以消除格式分叉和热路径中转
将图片主路径收口为 RGBA5551 输入、RGB565 backbuffer 输出,并删除 USE_RGB565_BACKBUFFER 双路径。sprite/tilemap 主绘制改为直接 RGBA5551 -> RGB565,透明语义固定为 1-bit alpha test。同步收口 SDL/FB present 路径、sprite 资源加载/导出规范,以及相关测试与文档。

Constraint: 运行时图片主路径需统一到 RGBA5551,不能继续保留主要 RGBA8888 分支
Constraint: 透明仅支持 1-bit 语义,不引入 alpha blending
Rejected: 保留 USE_RGB565_BACKBUFFER 宏双路径 | 持续增加维护成本并掩盖真实主链路
Rejected: 主绘制继续走 RGBA5551 -> RGBA8888 -> RGB565 | 热路径存在不必要中转
Confidence: high
Scope-risk: moderate
Directive: 后续图片资源默认按 RGBA5551 接入;不要重新引入通用 RGBA8888 主运行时路径
Directive: 字体 atlas 目前仍属兼容输入;若继续收口,优先改成 1-bit mask 而不是新的图片分支
Tested: render_pipeline_tests;SDL Demo Release 构建;搜索确认无 USE_RGB565_BACKBUFFER 残留
Not-tested: Linux framebuffer 目标实机构建与显示验证
2026-06-08 18:32:04 +08:00
HP 8785368913 修改Tom总流程,完善动画机的状态设置以及游戏逻辑;在core/platform新增win和板端的鼠标点击/触控的逻辑代码/驱动;测试游戏内容。 2026-06-08 12:53:38 +08:00
HP 24fe01af46 Merge branch 'master' of https://gitea.sepcomet.xyz/basil/IMX6U-Game 2026-06-08 10:27:33 +08:00
HP 1e34e15e04 重构SpriteAssertTool.cpp,减少中间文件产生;重构TomGame文件夹,删去不必要的文件;修改CMakeLists,PC重构Game自动重新构建资源头文件;修改文档 2026-06-08 10:27:31 +08:00
300 changed files with 445128 additions and 112632 deletions
+3 -6
View File
@@ -55,14 +55,11 @@ AGENTS.md
.omc
.omx
build-win
build-linux
build-arm-fb
build-arm-sdl
build-check
build-*
.idea
assets/test
gcc-linaro-4.9.4-2017.01-x86_64_arm-linux-gnueabihf.tar.xz
gcc-linaro-4.9.4-2017.01-x86_64_arm-linux-gnueabihf.tar.xz
/tools/__pycache__
+3
View File
@@ -0,0 +1,3 @@
{
"git.ignoreLimitWarning": true
}
+137 -24
View File
@@ -1,5 +1,6 @@
cmake_minimum_required(VERSION 3.16)
project(IMX6U-Game)
include(CTest)
set(CMAKE_CXX_STANDARD 11)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
@@ -8,8 +9,12 @@ if(NOT CMAKE_CONFIGURATION_TYPES AND NOT CMAKE_BUILD_TYPE)
set(CMAKE_BUILD_TYPE Release CACHE STRING "Build type" FORCE)
endif()
option(USE_FRAMEBUFFER "Use Linux framebuffer instead of SDL2" OFF)
option(USE_RGB565_BACKBUFFER "Use RGB565 internal backbuffer to eliminate present conversion on RGB565 fb0" OFF)
option(TARGET_IMX "Build for IMX6U target board (framebuffer + Alsa + Evdev). OFF builds for PC (SDL2)." OFF)
if(TARGET_IMX)
set(TARGET_PC OFF)
else()
set(TARGET_PC ON)
endif()
set(CORE_SOURCES
src/Core/Asset/ObjLoader.cpp
@@ -21,22 +26,29 @@ set(CORE_SOURCES
src/Core/Platform/AlsaAudioInput.cpp
src/Core/Platform/AlsaAudioOutput.cpp
src/Core/Platform/EvdevButtonInput.cpp
src/Core/Platform/EvdevKeyboardState.cpp
src/Core/Platform/EvdevTouchInput.cpp
src/Core/Rasterizer/Rasterizer.cpp
src/Core/Rasterizer/TriangleRasterizer.cpp
src/Core/Scene/Camera.cpp
src/Core/Shading/BlinnPhongShader.cpp
)
if(USE_FRAMEBUFFER)
list(APPEND CORE_SOURCES
src/Core/Platform/FBDisplay.cpp
)
if(TARGET_IMX)
list(APPEND CORE_SOURCES
src/Core/Platform/FBDisplay.cpp
src/Core/Platform/Ap3216cPhotoSensor.cpp
src/Core/Platform/Imx6ullAlphaLed.cpp
)
else()
list(APPEND CORE_SOURCES
src/Core/Platform/SDLDisplay.cpp
src/Core/Platform/SdlAudioInput.cpp
src/Core/Platform/SdlAudioOutput.cpp
src/Core/Platform/SdlKeyboardButtonInput.cpp
src/Core/Platform/SdlKeyboardState.cpp
src/Core/Platform/SdlPointerInput.cpp
src/Core/Platform/SdlPhotoSensor.cpp
)
endif()
@@ -54,18 +66,31 @@ set(CORE_INCLUDE_DIRS
assets/sprite
)
if(NOT TARGET_IMX)
list(APPEND CORE_SOURCES
third_party/imgui/imgui.cpp
third_party/imgui/imgui_demo.cpp
third_party/imgui/imgui_draw.cpp
third_party/imgui/imgui_tables.cpp
third_party/imgui/imgui_widgets.cpp
third_party/imgui/backends/imgui_impl_sdl2.cpp
third_party/imgui/backends/imgui_impl_sdlrenderer2.cpp
)
list(APPEND CORE_INCLUDE_DIRS third_party/imgui)
endif()
add_library(imx6u_core STATIC ${CORE_SOURCES})
target_include_directories(imx6u_core PUBLIC ${CORE_INCLUDE_DIRS})
if(USE_FRAMEBUFFER)
target_compile_definitions(imx6u_core PUBLIC USE_FRAMEBUFFER)
if(TARGET_IMX)
target_compile_definitions(imx6u_core PUBLIC TARGET_IMX)
else()
target_compile_definitions(imx6u_core PUBLIC TARGET_PC)
endif()
if(USE_RGB565_BACKBUFFER)
target_compile_definitions(imx6u_core PUBLIC USE_RGB565_BACKBUFFER)
endif()
target_compile_definitions(imx6u_core PUBLIC $<$<CONFIG:Debug>:IMX6U_DEBUG>)
if(USE_FRAMEBUFFER)
if(TARGET_IMX)
else()
if(WIN32)
if(CMAKE_SIZEOF_VOID_P EQUAL 8)
@@ -91,14 +116,50 @@ else()
endif()
if(UNIX AND NOT APPLE)
find_package(ALSA QUIET)
set(IMX6U_ALSA_INCLUDE_AFTER "")
if(TARGET_IMX)
set(IMX6U_BUNDLED_ALSA_ROOT "${CMAKE_CURRENT_SOURCE_DIR}/third_party/arm-linux-gnueabihf/alsa")
set(IMX6U_BUNDLED_ALSA_INCLUDE_DIR "${IMX6U_BUNDLED_ALSA_ROOT}/include")
set(IMX6U_BUNDLED_ALSA_LIBRARY "${IMX6U_BUNDLED_ALSA_ROOT}/lib/libasound.so")
if(EXISTS "${IMX6U_BUNDLED_ALSA_LIBRARY}")
set(ALSA_FOUND TRUE)
set(ALSA_LIBRARIES "${IMX6U_BUNDLED_ALSA_LIBRARY}")
if(EXISTS "${IMX6U_BUNDLED_ALSA_INCLUDE_DIR}/alsa/asoundlib.h")
set(ALSA_INCLUDE_DIRS "${IMX6U_BUNDLED_ALSA_INCLUDE_DIR}")
elseif(EXISTS "/usr/include/alsa/asoundlib.h")
set(IMX6U_ALSA_INCLUDE_AFTER "/usr/include")
else()
message(FATAL_ERROR "TARGET_IMX requires ALSA headers. Install libasound2-dev:armhf or copy ALSA headers to third_party/arm-linux-gnueabihf/alsa/include.")
endif()
message(STATUS "Using bundled ARM ALSA library: ${IMX6U_BUNDLED_ALSA_LIBRARY}")
else()
find_package(ALSA QUIET)
if(NOT ALSA_FOUND)
message(FATAL_ERROR "TARGET_IMX requires ALSA. Copy board libasound.so* to third_party/arm-linux-gnueabihf/alsa/lib or provide a compatible ALSA package.")
endif()
endif()
else()
find_package(ALSA QUIET)
endif()
if(ALSA_FOUND)
target_compile_definitions(imx6u_core PUBLIC PLATFORM_HAS_ALSA)
target_include_directories(imx6u_core PUBLIC ${ALSA_INCLUDE_DIRS})
if(IMX6U_ALSA_INCLUDE_AFTER)
target_compile_options(imx6u_core PUBLIC -idirafter ${IMX6U_ALSA_INCLUDE_AFTER})
elseif(ALSA_INCLUDE_DIRS)
target_include_directories(imx6u_core PUBLIC ${ALSA_INCLUDE_DIRS})
elseif(ALSA_INCLUDE_DIR)
target_include_directories(imx6u_core PUBLIC ${ALSA_INCLUDE_DIR})
endif()
target_link_libraries(imx6u_core PUBLIC ${ALSA_LIBRARIES})
if(TARGET_IMX)
target_link_libraries(imx6u_core PUBLIC dl pthread rt m)
endif()
else()
message(STATUS "ALSA was not found; AlsaAudioInput and AlsaAudioOutput will be unavailable backends")
endif()
elseif(TARGET_IMX)
message(FATAL_ERROR "TARGET_IMX requires ALSA headers and libasound.")
endif()
if(MSVC)
@@ -121,21 +182,12 @@ function(imx6u_configure_app_target target_name)
)
endif()
if(WIN32 AND NOT USE_FRAMEBUFFER)
if(WIN32 AND NOT TARGET_IMX)
add_custom_command(TARGET ${target_name} POST_BUILD
COMMAND ${CMAKE_COMMAND} -E copy_if_different
"${SDL2_DLL}"
"$<TARGET_FILE_DIR:${target_name}>"
)
elseif(SDL2_image_FOUND)
add_executable(SpriteAssetTool ${SPRITE_ASSET_TOOL_SOURCES})
target_include_directories(SpriteAssetTool PRIVATE
src/Core/Asset
src/Core/RenderData
)
target_link_libraries(SpriteAssetTool PRIVATE SDL2::SDL2 SDL2_image::SDL2_image)
else()
message(STATUS "SpriteAssetTool disabled: SDL2_image was not found")
endif()
if(MSVC)
@@ -145,4 +197,65 @@ function(imx6u_configure_app_target target_name)
endfunction()
add_subdirectory(src/Apps/Game)
add_subdirectory(src/Apps/Desktop)
add_subdirectory(src/Apps/Demo)
add_subdirectory(src/Apps/LightGame)
if(BUILD_TESTING AND NOT TARGET_IMX)
add_executable(render_pipeline_tests
tests/render_pipeline_tests.cpp
)
target_link_libraries(render_pipeline_tests PRIVATE imx6u_core)
target_include_directories(render_pipeline_tests PRIVATE ${CORE_INCLUDE_DIRS})
add_executable(game_engine_tests
tests/game_engine_tests.cpp
src/Apps/LightGame/src/engine/Level.cpp
src/Apps/LightGame/src/engine/Camera2D.cpp
src/Apps/LightGame/src/engine/Physics2D.cpp
src/Apps/LightGame/src/engine/LevelLoader.cpp
src/Apps/LightGame/src/systems/LightEffectSystem.cpp
)
target_include_directories(game_engine_tests PRIVATE
src/Apps/LightGame/src/engine
src/Apps/LightGame/src/systems
src/Apps/LightGame/src/levels
src/Apps/LightGame/generated
${CORE_INCLUDE_DIRS}
)
if(CMAKE_CONFIGURATION_TYPES)
foreach(config ${CMAKE_CONFIGURATION_TYPES})
string(TOUPPER "${config}" config_upper)
set_target_properties(render_pipeline_tests PROPERTIES
RUNTIME_OUTPUT_DIRECTORY_${config_upper} "${CMAKE_BINARY_DIR}/${config}"
)
set_target_properties(game_engine_tests PROPERTIES
RUNTIME_OUTPUT_DIRECTORY_${config_upper} "${CMAKE_BINARY_DIR}/${config}"
)
endforeach()
else()
set_target_properties(render_pipeline_tests PROPERTIES
RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}"
)
set_target_properties(game_engine_tests PROPERTIES
RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}"
)
endif()
if(WIN32)
add_custom_command(TARGET render_pipeline_tests POST_BUILD
COMMAND ${CMAKE_COMMAND} -E copy_if_different
"${SDL2_DLL}"
"$<TARGET_FILE_DIR:render_pipeline_tests>"
)
endif()
if(MSVC)
target_compile_options(render_pipeline_tests PRIVATE /utf-8 /W3)
target_compile_options(game_engine_tests PRIVATE /utf-8 /W3)
endif()
add_test(NAME render_pipeline_tests COMMAND render_pipeline_tests)
add_test(NAME game_engine_tests COMMAND game_engine_tests)
endif()
+130 -35
View File
@@ -20,7 +20,6 @@
- **Linux x86 编译**:验证代码在 GCC/Clang 下有无警告、CMake 配置是否跨平台、系统 SDL2 依赖是否正确。很多嵌入式工具链的问题在 x86 Linux 上就能提前暴露。
- **ARM 交叉编译**:最终在 IMX6U 上跑。若目标板使用 SDL2,则 SDL2 仅作为显示/输入适配层,时间由独立 `Platform::ITimeSource` 提供,核心渲染仍按 CPU framebuffer + 一次性提交设计;如需极简依赖,也保留 `/dev/fb0` 后端作为对照。
## 开发规范与性能红线
IMX6U 运行时性能预算较紧,后续开发必须遵守 `docs/DEVELOPMENT_GUIDELINES.md`。如果目标板使用 SDL2,仍然要把 SDL2 限制在平台适配层,核心逻辑和渲染热路径不直接依赖 SDL:
@@ -52,6 +51,26 @@ IMX6U 运行时性能预算较紧,后续开发必须遵守 `docs/DEVELOPMENT_G
cd IMX6U-Game
```
### 构建类型与调试开关
项目有两根独立的条件编译轴,组合出常用的构建形态:
| 轴 | CMake 参数 | 编译宏 | 默认 |
|---|---|---|---|
| 平台软件栈 | `-DTARGET_IMX=ON/OFF` | `TARGET_IMX` 或 `TARGET_PC`(互斥、双正向) | `OFF` → `TARGET_PC` |
| 构建类型 | `-DCMAKE_BUILD_TYPE=Debug/Release` | `IMX6U_DEBUG`(仅 Debug 配置注入) | `Release` |
`IMX6U_DEBUG` 控制开发期辅助内容是否参与构建。当前 LightGame 中下列内容只在 Debug 构建中存在,Release 构建会被完全剔除(不参与编译,不进入二进制):
- 关卡编辑器(`LevelEditor` + ImGui,按 `F1` 切换)
- 碰撞框可视化(`LevelRenderer::draw_debug` 与 `LightGameApp::debug_mode_`)
- `--debug` 命令行开关
- `[INFO] LightGame started …` 启动日志
硬件初始化失败的 `[WARN]` 提示无论 Debug/Release 都保留,作为现场诊断信息。
> 多配置生成器(Visual Studio)下 `CMAKE_BUILD_TYPE` 在 configure 阶段被忽略,应在构建阶段用 `--config Debug|Release` 选择;单配置生成器(Makefile/Ninja)使用 `-DCMAKE_BUILD_TYPE=...` 在 configure 阶段决定。
### Windows(Visual Studio / MSVC)
仓库已自带 SDL2 开发库(`libs/Win/SDL2`),无需额外安装。
@@ -62,22 +81,33 @@ cmake --build build-win --config Release
```
只构建某个 App:
```bash
cmake --build build-win --config Release --target IMX6U-Game
cmake --build build-win --config Release --target IMX6U-Demo
```
如果修改了 Tom 的原始 PNG 资源,先重新生成 atlas 头文件:
启用 LightGame 关卡编辑器和调试显示,使用 Debug 构建:
```bash
cmake --build build-win --config Debug --target IMX6U-LightGame
./build-win/Debug/IMX6U-LightGame.exe
```
构建 `IMX6U-Game` 时会自动重新生成 Tom 的 atlas 头文件;也可以单独执行:
```bash
cmake --build build-win --config Release --target GenerateTomAtlasHeader
```
运行:
```bash
./build-win/Release/IMX6U-Game.exe
```
可选帧率档位:
```bash
./build-win/Release/IMX6U-Game.exe --fps 30
./build-win/Release/IMX6U-Game.exe --fps 45
@@ -95,23 +125,27 @@ sudo apt-get install libsdl2-dev libsdl2-image-dev cmake g++
```
构建:
```bash
cmake -B build-linux .
cmake --build build-linux
```
只构建某个 App:
```bash
cmake --build build-linux --target IMX6U-Game
cmake --build build-linux --target IMX6U-Demo
```
如果修改了 Tom 的原始 PNG 资源,先重新生成 atlas 头文件:
构建 `IMX6U-Game` 时会自动重新生成 Tom 的 atlas 头文件;也可以单独执行:
```bash
cmake --build build-linux --target GenerateTomAtlasHeader
```
运行:
```bash
./build-linux/IMX6U-Game
```
@@ -129,25 +163,36 @@ sudo apt-get install gcc-arm-linux-gnueabihf g++-arm-linux-gnueabihf
- **SDL2 后端**:目标是后续游戏主路径;SDL2 负责显示、输入和最终 framebuffer 提交,时间源使用独立的 `Platform::ITimeSource`。
- **Framebuffer 后端**:作为极简依赖和显示通路对照测试。
> CMake 选项 `TARGET_IMX` 控制的不是"是否交叉编译",而是"使用哪一套软件栈":
> - `TARGET_IMX=ON` —— framebuffer (`/dev/fb0`) + Alsa 音频 + evdev 输入 + AP3216C 光敏,对应 IMX6U 板载硬件。
> - `TARGET_IMX=OFF` —— SDL2 显示/音频/输入/光敏,PC 调试和 ARM-SDL 对照路径共用这一栈。
>
> 因此 ARM 交叉编译 + SDL2 后端使用 `-DTARGET_IMX=OFF`,与"运行在 PC"无关。
构建(Framebuffer 对照后端):
```bash
cmake -B build-arm-fb \
-DCMAKE_TOOLCHAIN_FILE=cmake/toolchain-arm-linux-gnueabihf.cmake \
-DUSE_FRAMEBUFFER=ON .
-DTARGET_IMX=ON .
cmake --build build-arm-fb
```
说明:单配置生成器(Makefile/Ninja)默认使用 `Release` 构建;ARM / framebuffer 性能测试必须确认 `CMAKE_BUILD_TYPE=Release`,否则逐像素绘制和 `/dev/fb0` 提交会因未优化构建出现数量级偏差。
注意:Tom 的 atlas 头文件生成工具是主机侧离线工具,依赖 PC/Linux 主机上的 SDL2_image;主机构建 `IMX6U-Game` 时会自动执行,ARM 交叉编译过程不会执行它。如果修改了 `src/Apps/Game/assets/raw/` 里的 PNG,必须先在 Windows 或 Linux x86 构建目录执行 `GenerateTomAtlasHeader` 或构建一次 `IMX6U-Game`,再进行 ARM 交叉编译。ARM 构建只消费已经生成好的 `src/Apps/Game/generated/tom_atlas.h`。
构建(SDL2 后端,要求工具链/sysroot 可找到目标板 SDL2 开发库):
```bash
cmake -B build-arm-sdl \
-DCMAKE_TOOLCHAIN_FILE=cmake/toolchain-arm-linux-gnueabihf.cmake \
-DUSE_FRAMEBUFFER=OFF .
-DTARGET_IMX=OFF .
cmake --build build-arm-sdl
```
部署到开发板:
```bash
scp build-arm-sdl/IMX6U-Game root@imx6u:/tmp/
# 或部署 framebuffer 对照版本:
@@ -155,6 +200,7 @@ scp build-arm-fb/IMX6U-Game root@imx6u:/tmp/
```
板子上运行:
```bash
/tmp/IMX6U-Game
```
@@ -163,11 +209,12 @@ Framebuffer 对照版本可能需要 root 权限访问 `/dev/fb0`。SDL2 版本
## 资源转换工具
项目运行时不依赖 PNG/TTF 解码库。图片和字体资源在离线阶段转换成 C++ 头文件,运行时直接以 `uint32_t` 数组访问,像素格式统一为:
项目运行时不依赖 PNG/TTF 解码库。图片和字体资源在离线阶段转换成 C++ 头文件,但运行时格式按资源类型区分:
```text
(R << 24) | (G << 16) | (B << 8) | A
```
- sprite / atlas:`uint16_t` `RGBA5551`(5-bit R/G/B + 1-bit A)
- bitmap font:`uint8_t` 1-bit mask(8 个像素打包为 1 个字节)
sprite 运行时像素格式统一为 `RGBA5551`(16-bit),内部 backbuffer 统一为 `RGB565`;透明仅支持 1-bit alpha test(`A=0` 跳过,`A=1` 覆写)。
当前转换工具位于 `tools/`,需要 Python 和 Pillow:
@@ -188,12 +235,12 @@ python tools/png_to_header.py assets/sprite/test_sprite.png assets/sprite/test_s
```cpp
test_sprite_width
test_sprite_height
test_sprite_pixels
test_sprite_pixels // uint16_t RGBA5551 数组
```
透明像素仍保留 alpha;当前 demo 通过 `RenderData::Image(..., 0x00000000)` 把全透明像素作为 color key 跳过。
透明像素转换为 RGBA5551 后,alpha 仅 1-bit(`A=0` 透明,`A=1` 不透明)。当前 demo 通过 `RenderData::Image` 的 color_key 机制跳过指定颜色值。
Tom 游戏资源使用 `SpriteAssetTool --atlas-header` 从原始 PNG 一步生成 atlas 头文件:
Tom 游戏资源使用 `SpriteAssetTool` 从固定目录 `src/Apps/Game/assets/raw/` 读取原始 PNG,一步生成 atlas 头文件。主机侧构建 `IMX6U-Game` 时 CMake 会自动执行 `GenerateTomAtlasHeader`;也可以单独执行:
```bash
cmake --build build-win --config Release --target GenerateTomAtlasHeader
@@ -205,7 +252,19 @@ cmake --build build-win --config Release --target GenerateTomAtlasHeader
src/Apps/Game/generated/tom_atlas.h
```
该头文件包含 `tom_atlas_pixels` 和每张图的 `RenderData::SpriteRegion`,因此板端运行 Tom 游戏时不需要额外部署 `.sprite` 文件。
该头文件包含 `tom_atlas_pixels` 和每张图的 `RenderData::Sprite`,因此板端运行 Tom 游戏时不需要额外部署图片资源文件。尺寸规则、region 名称和 PNG 文件名统一记录在 `src/Apps/Game/tools/asset_pipeline/SpriteAssetTool.cpp` 顶部的 `Sources` 表里;CMake 不再重复维护每张 PNG 的路径。
`assets/sprite/` 用于存放测试用 PNG sprite 源文件及转换后的头文件,Tom 主游戏不依赖它。Tom 游戏的 atlas 资源由 `src/Apps/Game/tools/asset_pipeline/SpriteAssetTool.cpp` 从 `src/Apps/Game/assets/raw/` 读取原始 PNG 生成。
### LightGame Tile Atlas
LightGame 的 tile atlas 配置位于 `assets/tile/tile_atlas.json`,生成结果位于 `src/Apps/LightGame/generated/tile_atlas.h`:
```bash
python tools/pack_tile_atlas.py assets/tile/tile_atlas.json src/Apps/LightGame/generated/tile_atlas.h
```
当前 tile id 约定集中在 `src/Apps/LightGame/src/engine/TileIds.h`:`0` 是空透明 tile;`1..4` 是背景装饰;`5..11` 是可碰撞地形;`12`/`22` 是伤害尖刺;`13..18` 是光照平台和门的状态贴图;`19..21` 是金币、旗帜和 checkpoint;`23` 是藤蔓装饰。LightGame 关卡支持可选 `background_tiles` 视觉层,该层先于主 tilemap 渲染,不参与碰撞、死亡、触发器或光照 tile 规则。
### Bitmap Font 转换
@@ -228,7 +287,13 @@ assets/font/font_atlas.png
assets/font/font_atlas.h
```
字体范围为 ASCII 32~126,按 16 列排列。生成端会把字体 alpha 阈值化为 0/255,以匹配当前 `DrawContext::draw_text` 的像素风路径;运行时只把非透明像素替换成调用方指定颜色。
字体范围为 ASCII 32~126,按 16 列排列。生成端会先把字体 alpha 阈值化为 0/255,再按整张 atlas 逐行打包成 **row-major、MSB-first** 的 1-bit mask:
- `font_atlas_mask[]` 中每个 `uint8_t` 表示连续 8 个像素
- bit=1 表示该像素需要绘制
- bit=0 表示跳过
运行时 `DrawContext::draw_text` 不再读取字体 atlas 的 RGB/A 颜色,而是直接把 mask 中 bit=1 的像素写成调用方传入文字颜色对应的 `RGB565`。
## 显示后端架构
@@ -249,7 +314,7 @@ assets/font/font_atlas.h
└──────────────────────────────────────────────┘
```
切换显示后端不应影响应用层和核心绘制逻辑;当前 CMake 通过 `USE_FRAMEBUFFER` 在 SDL2 与 framebuffer 后端间切换。
切换显示后端不应影响应用层和核心绘制逻辑;当前 CMake 通过 `TARGET_IMX` 在 framebuffer (IMX6U) 与 SDL2 (PC) 后端间切换,对应代码层的 `TARGET_IMX` / `TARGET_PC` 双正向宏。
## 目录结构
@@ -266,20 +331,32 @@ IMX6U-Game/
│ └─ sprite/ # PNG sprite 源文件及转换后的头文件
├─ tools/
│ ├─ gen_font_atlas.py # TTF -> bitmap font atlas/header
│ └─ png_to_header.py # PNG -> uint32_t RGBA header
│ └─ png_to_header.py # PNG -> uint16_t RGBA5551 header
├─ src/
│ ├─ Core/ # 底层图形库:可复用、无具体游戏规则
│ │ ├─ Draw2D/ # DrawContext 统一绘制入口
│ │ ├─ Core/ # FrameBuffer、DepthBuffer
│ │ ├─ Core/ # FrameBuffer、DepthBuffer、Renderer、Timer
│ │ ├─ Math/ # 向量、矩阵、数学工具
│ │ ├─ Rasterizer/ # 线段、三角形光栅化
│ │ ├─ RenderData/ # Color、Triangle 等数据结构
│ │ ├─ Scene/ # Camera、Transform、Mesh
│ │ ├─ Shading/ # 着色器(预留)
│ │ ├─ RenderData/ # Color、Image、Sprite、Tilemap、BitmapFont 等数据结构
│ │ ├─ Scene/ # Camera、Transform、Mesh、Model
│ │ ├─ Shading/ # BlinnPhongShader 等着色器
│ │ ├─ Platform/ # IDisplay、SDLDisplay、FBDisplay、ITimeSource
│ │ └─ Asset/ # ObjLoader 等资源加载
│ │ │ # IAudioInput/Output、IButtonInput、IPointerInput
│ │ │ # ALSA / evdev / SDL2 后端、DefaultHardware
│ │ └─ Asset/ # ObjLoader、SpriteAssetLoader
│ ├─ Apps/
│ │ └─ Demo/ # 当前 3D 立方体 demo 入口
│ │ ├─ Demo/ # 2D sprite/tilemap 性能测试入口
│ │ └─ Game/ # Tom 游戏
│ │ ├─ Main.cpp # 游戏入口
│ │ ├─ generated/tom_atlas.h # 自动生成的 atlas 头文件
│ │ ├─ tools/asset_pipeline/ # SpriteAssetTool(离线资源转换)
│ │ └─ src/
│ │ ├─ app/ # TomGameApp
│ │ ├─ audio/ # VoiceEffect、VoicePlayer、VoiceRecorder
│ │ ├─ components/ # SpriteAnimator
│ │ ├─ scenes/ # TomScene
│ │ └─ systems/ # AnimationSystem
│ └─ test_fb.cpp # 独立 fb 测试(最小示例)
├─ docs/
│ ├─ DEVELOPMENT_GUIDELINES.md # IMX6U 性能红线
@@ -292,40 +369,56 @@ IMX6U-Game/
## 模块说明
### Draw2D
- **DrawContext**:统一绘制入口,封装 FrameBuffer、DepthBuffer、Rasterizer、TriangleRasterizer,对外提供 `clear`、`draw_line`、`draw_triangle`、`draw_sprite`、`draw_sprite_region`、`draw_text`、`draw_tilemap`、`present` 接口
- **SpriteRegion**:描述 atlas 中的子区域,`draw_sprite_region` / `draw_sprite_region_ex` 可直接绘制子图,底层复用 `draw_sprite_ex`
- **Tilemap**:使用 `uint16_t` tile id 引用 atlas 中的固定大小 tile,`draw_tilemap` 按视口可见范围遍历 tile,并在视口边缘做像素级裁剪
- **DrawContext**:统一绘制入口,封装 FrameBuffer、DepthBuffer、Rasterizer、TriangleRasterizer,对外提供 `clear`、`clear_color`、`clear_depth`、`draw_line`、`draw_triangle`、`draw_sprite`、`draw_sprite_ex`、`draw_text`、`draw_tilemap`、`fill_rect`、`present` 接口
### RenderData
- **Image**:通用图像数据结构,持有 `const void* pixels` 和 `PixelFormat`(当前统一 `RGBA5551`),支持 color_key 透明跳过
- **Sprite**:描述 atlas 中的子区域,通过 `const Image* atlas` 引用源图,是对外 sprite 绘制单位
- **Tilemap**:使用 `uint16_t` tile id 引用 atlas 中的固定大小 tile,`EmptyTile` (`0xFFFF`) 表示空 tile
- **BitmapFont**:bitmap 字体数据,持有 `uint8_t* mask_bits`(row-major、MSB-first 1-bit mask),支持 ASCII 范围字符绘制
- **Color**:RGBA8888 颜色值,用于绘制接口参数和调试;sprite 运行时像素格式仍为 RGBA5551
### Core
- **FrameBuffer**:CPU 侧颜色缓冲,渲染结果先写在这里
- **FrameBuffer**:CPU 侧 RGB565 颜色缓冲,渲染结果先写在这里
- **DepthBuffer**:深度测试用 Z-buffer
- **Renderer**:渲染器辅助工具
- **Timer**:整数毫秒固定步长 tick 生成器,支持 30/45/60 FPS 档位和每帧剩余时间计算
### Math
- 通用数学类型:`Vector2/3/4`、`Matrix4x4`
- 纯头文件实现,无动态分配
### Rasterizer
- **Rasterizer**:Bresenham 线段光栅化
- **TriangleRasterizer**:扫描线三角形填充 + 深度测试
- **Rasterizer**:Bresenham 线段光栅化,入口做快速全屏可见性检查,屏幕内走 `set_pixel_unsafe` 快路径,屏幕外走 Cohen-Sutherland 裁剪
- **TriangleRasterizer**:扫描线三角形填充 + 定点深度插值(增量式整数边缘函数,内层循环无 float 运算)
### Platform
- **IDisplay**:显示后端抽象,解耦渲染与输出
- **SDLDisplay**:SDL2 后端,PC 调试和 IMX6U SDL2 目标路径共用这一类适配思想
- **FBDisplay**:`/dev/fb0` 对照后端,用于极简显示通路验证
- **ITimeSource / SteadyTimeSource**:独立时间源接口与单调时钟实现;Linux/IMX6U 使用 `clock_gettime(CLOCK_MONOTONIC)`,Windows 使用 `std::chrono::steady_clock`,Display 不再承担计时职责
- **IAudioInput / IAudioOutput**:音频输入/输出抽象接口,SDL2 和 ALSA 两套后端
- **IButtonInput**:按键输入抽象接口,SDL2 键盘和 Linux evdev 两套后端
- **IPointerInput**:触摸/指针输入抽象接口,SDL2 和 Linux evdev 两套后端
- **DefaultHardware**:根据编译配置自动选择默认音频、按键、指针后端的 typedef
### Framebuffer 性能说明
`FBDisplay` 是直接写 `/dev/fb0` 的对照后端。当前实现会从 CPU 侧 `FrameBuffer` 提交到系统 framebuffer,并针对常见像素格式提供快速路径:
`FBDisplay` 是直接写 `/dev/fb0` 的对照后端。当前实现会从 CPU 侧 `RGB565 FrameBuffer` 提交到系统 framebuffer,并针对常见像素格式提供快速路径:
- RGB565:使用专用 RGBA -> RGB565 转换;
- ARGB8888 / XRGB8888 类 32bpp:使用专用通道重排;
- RGBA8888 且行宽连续时:整块 `memcpy`。
- RGB565:直接行拷贝;
- ARGB8888 / XRGB8888 类 32bpp:使用专用 `RGB565 -> 32-bit` 转换;
- 其他格式:走统一 display conversion。
板端性能测试必须使用 Release 构建。一次测试中,未优化 ARM 构建曾导致 `Frame:81ms / Present:69ms`,开启 Release 后同一轻量 2D demo 可达到约 76 FPS。该结果说明 `/dev/fb0` 整屏提交仍是关键热点,但构建类型会极大影响结论。后续优化优先级:
1. 直接使用与目标 fb0 一致的 backbuffer 像素格式,例如 RGB565,减少提交时转换;
1. 对 32-bit fb0 增加更快的 `RGB565 -> 目标格式` 批量转换;
2. 2D 场景使用 dirty rect / 局部提交,避免每帧整屏写入;
3. 避免无 3D 内容时清理 depth buffer;
4. 对 tile/sprite 增加不透明行拷贝、预转换资源或专用批处理路径。
@@ -333,16 +426,18 @@ IMX6U-Game/
## 当前状态与后续
**已完成:**
- 可旋转立方体的 3D 渲染(MVP 变换、背面剔除、扫描线填充、深度测试)
- 双平台显示后端(SDL2 / Framebuffer)
- 离线资源转换工具:PNG sprite -> C++ 头文件,像素字体 -> bitmap atlas/header
- 基础 2D sprite、SpriteRegion、bitmap font 文本绘制和 tilemap 视口绘制,当前 demo 显示 FPS 文本、测试 sprite 和小型滚动 tilemap
- 离线资源转换工具:PNG sprite -> RGBA5551 C++ 头文件,像素字体 -> bitmap atlas/header
- 基础 2D sprite、atlas 子图 sprite、bitmap font 文本绘制和 tilemap 视口绘制,当前 demo 显示 FPS 文本、测试 sprite 和小型滚动 tilemap
- Core 目录规范化,代码收敛到 `src/Core/`
- `Core::DrawContext` 统一绘制入口,封装现有绘制能力
- C++11 兼容代码
- CMake 跨平台构建
**待完成(按优先级):**
1. FrameBuffer / FBDisplay 性能优化(目标像素格式 backbuffer、dirty rect、专用 tile/sprite 快路径、NEON)
2. 应用层拆分(Launcher / GameA / GameB / Shared)和统一 `IApp` 主循环
3. SDL2 输入抽象(键盘/触摸/按键状态快照)
+2919
View File
File diff suppressed because it is too large Load Diff
+177 -1405
View File
File diff suppressed because it is too large Load Diff
+25 -25
View File
@@ -1,25 +1,25 @@
// Auto-generated by tools/png_to_header.py
#pragma once
#include <cstdint>
static const int32_t test_sprite_width = 16;
static const int32_t test_sprite_height = 16;
static const uint32_t test_sprite_pixels[] = {
0x00000000, 0x00000000, 0x00000000, 0x00000000, 0x00000000, 0x00000000, 0x00000000, 0x00000000, 0x00000000, 0x00000000, 0x00000000, 0x00000000, 0x00000000, 0x00000000, 0x00000000, 0x00000000,
0x00000000, 0x00000000, 0x00000000, 0x00000000, 0x00000000, 0x00000000, 0x00000000, 0x00000000, 0x00000000, 0x00000000, 0x00000000, 0x00000000, 0x00000000, 0x00000000, 0x00000000, 0x00000000,
0x00000000, 0x00000000, 0xDC2828FF, 0xDC2828FF, 0xDC2828FF, 0xDC2828FF, 0xDC2828FF, 0xDC2828FF, 0xDC2828FF, 0xDC2828FF, 0xDC2828FF, 0xDC2828FF, 0xDC2828FF, 0xDC2828FF, 0x00000000, 0x00000000,
0x00000000, 0x00000000, 0xDC2828FF, 0xDC2828FF, 0xDC2828FF, 0xDC2828FF, 0xDC2828FF, 0xDC2828FF, 0xDC2828FF, 0xDC2828FF, 0xDC2828FF, 0xDC2828FF, 0xDC2828FF, 0xDC2828FF, 0x00000000, 0x00000000,
0x00000000, 0x00000000, 0xDC2828FF, 0xDC2828FF, 0xFF6464FF, 0xFF6464FF, 0xFF6464FF, 0xFF6464FF, 0xFF6464FF, 0xFF6464FF, 0xFF6464FF, 0xFF6464FF, 0xDC2828FF, 0xDC2828FF, 0x00000000, 0x00000000,
0x00000000, 0x00000000, 0xDC2828FF, 0xDC2828FF, 0xFF6464FF, 0xFF6464FF, 0xFF6464FF, 0xFF6464FF, 0xFF6464FF, 0xFF6464FF, 0xFF6464FF, 0xFF6464FF, 0xDC2828FF, 0xDC2828FF, 0x00000000, 0x00000000,
0x00000000, 0x00000000, 0xDC2828FF, 0xDC2828FF, 0xFF6464FF, 0xFF6464FF, 0xFF6464FF, 0xFF6464FF, 0xFF6464FF, 0xFF6464FF, 0xFF6464FF, 0xFF6464FF, 0xDC2828FF, 0xDC2828FF, 0x00000000, 0x00000000,
0x00000000, 0x00000000, 0xDC2828FF, 0xDC2828FF, 0xFF6464FF, 0xFF6464FF, 0xFF6464FF, 0xFF6464FF, 0xFF6464FF, 0xFF6464FF, 0xFF6464FF, 0xFF6464FF, 0xDC2828FF, 0xDC2828FF, 0x00000000, 0x00000000,
0x00000000, 0x00000000, 0xDC2828FF, 0xDC2828FF, 0xFF6464FF, 0xFF6464FF, 0xFF6464FF, 0xFF6464FF, 0xFF6464FF, 0xFF6464FF, 0xFF6464FF, 0xFF6464FF, 0xDC2828FF, 0xDC2828FF, 0x00000000, 0x00000000,
0x00000000, 0x00000000, 0xDC2828FF, 0xDC2828FF, 0xFF6464FF, 0xFF6464FF, 0xFF6464FF, 0xFF6464FF, 0xFF6464FF, 0xFF6464FF, 0xFF6464FF, 0xFF6464FF, 0xDC2828FF, 0xDC2828FF, 0x00000000, 0x00000000,
0x00000000, 0x00000000, 0xDC2828FF, 0xDC2828FF, 0xFF6464FF, 0xFF6464FF, 0xFF6464FF, 0xFF6464FF, 0xFF6464FF, 0xFF6464FF, 0xFF6464FF, 0xFF6464FF, 0xDC2828FF, 0xDC2828FF, 0x00000000, 0x00000000,
0x00000000, 0x00000000, 0xDC2828FF, 0xDC2828FF, 0xFF6464FF, 0xFF6464FF, 0xFF6464FF, 0xFF6464FF, 0xFF6464FF, 0xFF6464FF, 0xFF6464FF, 0xFF6464FF, 0xDC2828FF, 0xDC2828FF, 0x00000000, 0x00000000,
0x00000000, 0x00000000, 0xDC2828FF, 0xDC2828FF, 0xDC2828FF, 0xDC2828FF, 0xDC2828FF, 0xDC2828FF, 0xDC2828FF, 0xDC2828FF, 0xDC2828FF, 0xDC2828FF, 0xDC2828FF, 0xDC2828FF, 0x00000000, 0x00000000,
0x00000000, 0x00000000, 0xDC2828FF, 0xDC2828FF, 0xDC2828FF, 0xDC2828FF, 0xDC2828FF, 0xDC2828FF, 0xDC2828FF, 0xDC2828FF, 0xDC2828FF, 0xDC2828FF, 0xDC2828FF, 0xDC2828FF, 0x00000000, 0x00000000,
0x00000000, 0x00000000, 0x00000000, 0x00000000, 0x00000000, 0x00000000, 0x00000000, 0x00000000, 0x00000000, 0x00000000, 0x00000000, 0x00000000, 0x00000000, 0x00000000, 0x00000000, 0x00000000,
0x00000000, 0x00000000, 0x00000000, 0x00000000, 0x00000000, 0x00000000, 0x00000000, 0x00000000, 0x00000000, 0x00000000, 0x00000000, 0x00000000, 0x00000000, 0x00000000, 0x00000000, 0x00000000,
};
// Auto-generated by tools/png_to_header.py
#pragma once
#include <cstdint>
static const int32_t test_sprite_width = 16;
static const int32_t test_sprite_height = 16;
static const uint16_t test_sprite_pixels[] = {
0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000,
0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000,
0x0000, 0x0000, 0xD94B, 0xD94B, 0xD94B, 0xD94B, 0xD94B, 0xD94B, 0xD94B, 0xD94B, 0xD94B, 0xD94B, 0xD94B, 0xD94B, 0x0000, 0x0000,
0x0000, 0x0000, 0xD94B, 0xD94B, 0xD94B, 0xD94B, 0xD94B, 0xD94B, 0xD94B, 0xD94B, 0xD94B, 0xD94B, 0xD94B, 0xD94B, 0x0000, 0x0000,
0x0000, 0x0000, 0xD94B, 0xD94B, 0xFB19, 0xFB19, 0xFB19, 0xFB19, 0xFB19, 0xFB19, 0xFB19, 0xFB19, 0xD94B, 0xD94B, 0x0000, 0x0000,
0x0000, 0x0000, 0xD94B, 0xD94B, 0xFB19, 0xFB19, 0xFB19, 0xFB19, 0xFB19, 0xFB19, 0xFB19, 0xFB19, 0xD94B, 0xD94B, 0x0000, 0x0000,
0x0000, 0x0000, 0xD94B, 0xD94B, 0xFB19, 0xFB19, 0xFB19, 0xFB19, 0xFB19, 0xFB19, 0xFB19, 0xFB19, 0xD94B, 0xD94B, 0x0000, 0x0000,
0x0000, 0x0000, 0xD94B, 0xD94B, 0xFB19, 0xFB19, 0xFB19, 0xFB19, 0xFB19, 0xFB19, 0xFB19, 0xFB19, 0xD94B, 0xD94B, 0x0000, 0x0000,
0x0000, 0x0000, 0xD94B, 0xD94B, 0xFB19, 0xFB19, 0xFB19, 0xFB19, 0xFB19, 0xFB19, 0xFB19, 0xFB19, 0xD94B, 0xD94B, 0x0000, 0x0000,
0x0000, 0x0000, 0xD94B, 0xD94B, 0xFB19, 0xFB19, 0xFB19, 0xFB19, 0xFB19, 0xFB19, 0xFB19, 0xFB19, 0xD94B, 0xD94B, 0x0000, 0x0000,
0x0000, 0x0000, 0xD94B, 0xD94B, 0xFB19, 0xFB19, 0xFB19, 0xFB19, 0xFB19, 0xFB19, 0xFB19, 0xFB19, 0xD94B, 0xD94B, 0x0000, 0x0000,
0x0000, 0x0000, 0xD94B, 0xD94B, 0xFB19, 0xFB19, 0xFB19, 0xFB19, 0xFB19, 0xFB19, 0xFB19, 0xFB19, 0xD94B, 0xD94B, 0x0000, 0x0000,
0x0000, 0x0000, 0xD94B, 0xD94B, 0xD94B, 0xD94B, 0xD94B, 0xD94B, 0xD94B, 0xD94B, 0xD94B, 0xD94B, 0xD94B, 0xD94B, 0x0000, 0x0000,
0x0000, 0x0000, 0xD94B, 0xD94B, 0xD94B, 0xD94B, 0xD94B, 0xD94B, 0xD94B, 0xD94B, 0xD94B, 0xD94B, 0xD94B, 0xD94B, 0x0000, 0x0000,
0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000,
0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000,
};
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 219 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 240 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 133 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 174 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 301 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 155 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 203 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 829 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 800 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 813 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 845 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 824 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 803 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 806 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 822 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 154 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 211 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

+14
View File
@@ -0,0 +1,14 @@
{
"atlas_width": 256,
"atlas_height": 128,
"sprites": [
{ "name": "player_idle_0", "file": "player_idle_0.png" },
{ "name": "player_idle_1", "file": "player_idle_1.png" },
{ "name": "player_run_0", "file": "player_run_0.png" },
{ "name": "player_run_1", "file": "player_run_1.png" },
{ "name": "player_run_2", "file": "player_run_2.png" },
{ "name": "player_run_3", "file": "player_run_3.png" },
{ "name": "player_jump", "file": "player_jump.png" },
{ "name": "player_fall", "file": "player_fall.png" }
]
}
+30
View File
@@ -0,0 +1,30 @@
{
"tile_size": 32,
"columns": 6,
"tiles": [
{ "id": 0, "file": "empty.png" },
{ "id": 1, "file": "background_1.png" },
{ "id": 2, "file": "background_2.png" },
{ "id": 3, "file": "background_3.png" },
{ "id": 4, "file": "background_4.png" },
{ "id": 5, "file": "ground_top_green.png" },
{ "id": 6, "file": "ground_fill_green.png" },
{ "id": 7, "file": "ground_top_gray.png" },
{ "id": 8, "file": "ground_fill_gray.png" },
{ "id": 9, "file": "ground_top_purple.png" },
{ "id": 10, "file": "ground_fill_purple.png" },
{ "id": 11, "file": "platform.png" },
{ "id": 12, "file": "spike.png" },
{ "id": 13, "file": "light_platform_off.png" },
{ "id": 14, "file": "light_platform_on.png" },
{ "id": 15, "file": "shadow_platform_off.png" },
{ "id": 16, "file": "shadow_platform_on.png" },
{ "id": 17, "file": "door_closed.png" },
{ "id": 18, "file": "door_open.png" },
{ "id": 19, "file": "coin.png" },
{ "id": 20, "file": "flag.png" },
{ "id": 21, "file": "checkpoint.png" },
{ "id": 22, "file": "spike_ceiling.png" },
{ "id": 23, "file": "decor_vine.png" }
]
}
-89
View File
@@ -1,89 +0,0 @@
# This is the CMakeCache file.
# For build in directory: e:/嵌入式实验/实训/IMX6U-Game/build-check
# It was generated by CMake: C:/Program Files/CMake/bin/cmake.exe
# You can edit this file to change values found and used by cmake.
# If you do not want to change any of the values, simply exit the editor.
# If you do want to change a value, simply edit, save, and exit the editor.
# The syntax for the file is as follows:
# KEY:TYPE=VALUE
# KEY is the name of a variable in the cache.
# TYPE is a hint to GUIs for the type of VALUE, DO NOT EDIT TYPE!.
# VALUE is the current value for the KEY.
########################
# EXTERNAL cache entries
########################
//Semicolon separated list of supported configuration types, only
// supports Debug, Release, MinSizeRel, and RelWithDebInfo, anything
// else will be ignored.
CMAKE_CONFIGURATION_TYPES:STRING=Debug;Release;MinSizeRel;RelWithDebInfo
//Value Computed by CMake.
CMAKE_FIND_PACKAGE_REDIRECTS_DIR:STATIC=E:/嵌入式实验/实训/IMX6U-Game/build-check/CMakeFiles/pkgRedirects
//Value Computed by CMake
CMAKE_PROJECT_COMPAT_VERSION:STATIC=
//Value Computed by CMake
CMAKE_PROJECT_DESCRIPTION:STATIC=
//Value Computed by CMake
CMAKE_PROJECT_HOMEPAGE_URL:STATIC=
//Value Computed by CMake
CMAKE_PROJECT_NAME:STATIC=IMX6U-Game
//Value Computed by CMake
CMAKE_PROJECT_SPDX_LICENSE:STATIC=
//Value Computed by CMake
IMX6U-Game_BINARY_DIR:STATIC=E:/嵌入式实验/实训/IMX6U-Game/build-check
//Value Computed by CMake
IMX6U-Game_IS_TOP_LEVEL:STATIC=ON
//Value Computed by CMake
IMX6U-Game_SOURCE_DIR:STATIC=E:/嵌入式实验/实训/IMX6U-Game
########################
# INTERNAL cache entries
########################
//This is the directory where this CMakeCache.txt was created
CMAKE_CACHEFILE_DIR:INTERNAL=e:/嵌入式实验/实训/IMX6U-Game/build-check
//Major version of cmake used to create the current loaded cache
CMAKE_CACHE_MAJOR_VERSION:INTERNAL=4
//Minor version of cmake used to create the current loaded cache
CMAKE_CACHE_MINOR_VERSION:INTERNAL=3
//Patch version of cmake used to create the current loaded cache
CMAKE_CACHE_PATCH_VERSION:INTERNAL=3
//Path to CMake executable.
CMAKE_COMMAND:INTERNAL=C:/Program Files/CMake/bin/cmake.exe
//Path to cpack program executable.
CMAKE_CPACK_COMMAND:INTERNAL=C:/Program Files/CMake/bin/cpack.exe
//Path to ctest program executable.
CMAKE_CTEST_COMMAND:INTERNAL=C:/Program Files/CMake/bin/ctest.exe
//Name of external makefile project generator.
CMAKE_EXTRA_GENERATOR:INTERNAL=
//Name of generator.
CMAKE_GENERATOR:INTERNAL=Visual Studio 17 2022
//Generator instance identifier.
CMAKE_GENERATOR_INSTANCE:INTERNAL=C:/Program Files/Microsoft Visual Studio/2022/Community
//Name of generator platform.
CMAKE_GENERATOR_PLATFORM:INTERNAL=
//Name of generator toolset.
CMAKE_GENERATOR_TOOLSET:INTERNAL=
//Source directory with the top level CMakeLists.txt file for this
// project
CMAKE_HOME_DIRECTORY:INTERNAL=E:/嵌入式实验/实训/IMX6U-Game
//Name of CMakeLists files to read
CMAKE_LIST_FILE_NAME:INTERNAL=CMakeLists.txt
//number of local generators
CMAKE_NUMBER_OF_MAKEFILES:INTERNAL=1
//Platform information initialized
CMAKE_PLATFORM_INFO_INITIALIZED:INTERNAL=1
//Path to CMake installation.
CMAKE_ROOT:INTERNAL=C:/Program Files/CMake/share/cmake-4.3
+6 -2
View File
@@ -1,8 +1,12 @@
set(CMAKE_SYSTEM_NAME Linux)
set(CMAKE_SYSTEM_PROCESSOR arm)
set(CMAKE_C_COMPILER arm-linux-gnueabihf-gcc)
set(CMAKE_CXX_COMPILER arm-linux-gnueabihf-g++)
# Linaro GCC 4.9.4 2017.01,与 IMX6U 开发板原厂一致
# 解压:tar -xJf gcc-linaro-4.9.4-2017.01-x86_64_arm-linux-gnueabihf.tar.xz -C ~/toolchains
set(IMX6U_TOOLCHAIN_ROOT "$ENV{HOME}/toolchains/gcc-linaro-4.9.4-2017.01-x86_64_arm-linux-gnueabihf")
set(CMAKE_C_COMPILER "${IMX6U_TOOLCHAIN_ROOT}/bin/arm-linux-gnueabihf-gcc")
set(CMAKE_CXX_COMPILER "${IMX6U_TOOLCHAIN_ROOT}/bin/arm-linux-gnueabihf-g++")
set(CMAKE_SYSROOT "${IMX6U_TOOLCHAIN_ROOT}/arm-linux-gnueabihf/libc")
set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER)
set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY)
+18 -10
View File
@@ -21,6 +21,14 @@ src/
Apps/
Demo/ # 2D sprite/tilemap 性能测试入口
Game/ # Tom 游戏
generated/ # 自动生成的 tom_atlas.h
tools/ # SpriteAssetTool(离线资源转换)
src/
app/ # TomGameApp
audio/ # VoiceEffect、VoicePlayer、VoiceRecorder
components/ # SpriteAnimator
scenes/ # TomScene
systems/ # AnimationSystem
```
`src/Core/Core` 这个二级目录保留的是原底层库里的核心运行时对象。它和顶层 `src/Core` 名称重复,但含义不同:
@@ -77,7 +85,7 @@ IMX6U-Game/
职责:
- 管理 framebuffer、depthbuffer、渲染上下文。
- 提供基础绘制接口:点、线、矩形、四边形、三角形、sprite、SpriteRegion、tilemap、简单文本等。
- 提供基础绘制接口:点、线、矩形、四边形、三角形、sprite、atlas 子图 sprite、tilemap、简单文本等。
- 提供颜色、矩形、定点数、纹理、裁剪区域等基础数据结构。
- 封装 SDL2 / framebuffer 显示提交、输入轮询,并通过独立 `ITimeSource` 提供单调整数毫秒时间。
- 提供音频输入、音频输出和按键输入的抽象接口。
@@ -211,8 +219,8 @@ ALSA、evdev、SDL2、`/dev/fb0` 等平台细节只能出现在 `src/Core/Platfo
### 5.7 CMake 后端切换
```cmake
-DUSE_FRAMEBUFFER=OFF # 默认,使用 SDLDisplay
-DUSE_FRAMEBUFFER=ON # 使用 FBDisplay
-DTARGET_IMX=OFF # 默认 (PC),使用 SDLDisplay
-DTARGET_IMX=ON # IMX6U 板,使用 FBDisplay
```
## 6. DrawContext
@@ -231,7 +239,7 @@ ALSA、evdev、SDL2、`/dev/fb0` 等平台细节只能出现在 `src/Core/Platfo
```cpp
Core::DrawContext ctx(width, height);
ctx.clear(RenderData::Color(18, 18, 24, 255));
ctx.draw_sprite(x, y, image);
ctx.draw_sprite(x, y, sprite);
ctx.draw_text(font, x, y, color, "text");
ctx.present(display);
```
@@ -281,7 +289,7 @@ poll input -> update current app -> render current app -> present framebuffer
1. ~~先抽出统一 `IApp` 和 `AppManager`,让当前 demo 成为一个 app。~~ 未实现,当前直接写主循环。
2. ~~把 SDL2 初始化、输入、present 固定在平台层,应用层不直接碰 SDL。~~ **已完成**
3. ~~建立 `Core::DrawContext`,先封装 clear、pixel、line、rect、quad。~~ **已完成**(`Core::DrawContext` 封装了 clear、draw_line、draw_triangle、draw_sprite、draw_sprite_region、draw_text、draw_tilemap、present)
3. ~~建立 `Core::DrawContext`,先封装 clear、pixel、line、rect、quad。~~ **已完成**(`Core::DrawContext` 封装了 clear、draw_line、draw_triangle、draw_sprite、draw_text、draw_tilemap、present)
4. ~~底层代码统一放在 `src/Core/`,Demo 入口迁移到 `src/Apps/Demo/`。~~ **已完成**
5. 新增 Launcher app,只做最小菜单和应用切换。
6. 新增 GameA/GameB 空壳,验证三应用切换。
@@ -305,14 +313,14 @@ poll input -> update current app -> render current app -> present framebuffer
- 运行时资源优先使用离线转换后的简单数组,不在 IMX6U 运行时解码 PNG/TTF。
- `tools/png_to_header.py` 将 PNG 转为 `uint32_t` RGBA 数组,适用于 sprite、小图标、测试纹理等。
- `tools/gen_font_atlas.py` 将共享像素字体转为 ASCII bitmap atlas,并输出同名 PNG 预览和 C++ 头文件。
- `tools/gen_font_atlas.py` 将共享像素字体转为 ASCII bitmap atlas,并输出同名 PNG 预览和 C++ 头文件;运行时字体数据使用 `uint8_t` 1-bit mask(row-major、MSB-first)。
- 生成头文件、源 PNG/TTF 和转换脚本应一起纳入仓库,保证资源可追溯、可再生成。
- 生成数据目前面向简单直接的调试/小型游戏资源;后续如果资源体积增长,应再评估 1-bit/8-bit mask、RLE 或自定义资源包格式。
- 生成数据目前面向简单直接的调试/小型游戏资源;后续如果资源体积增长,应继续评估 1-bit mask、RLE 或自定义资源包格式。
## 12. SpriteRegion 与 Tilemap 约定
## 12. Sprite 与 Tilemap 约定
- `RenderData::SpriteRegion` 只描述某张 atlas 中的子区域,不拥有像素数据;它通过 `const Image* atlas` 引用源图。
- `DrawContext::draw_sprite_ex` 是底层 sprite 绘制入口,负责源区域检查、目标屏幕裁剪、scale 和 flip;`draw_sprite_region` 系列只是对 atlas 子区域的语义包装。
- `RenderData::Sprite` 只描述某张 atlas 中的子区域,不拥有像素数据;它通过 `const Image* atlas` 引用源图。
- `DrawContext::draw_sprite` 是对外 sprite 绘制入口;`draw_sprite_ex` 只在需要 scale / flip 时使用。两者都以 `RenderData::Sprite` 为渲染单位,内部再按 atlas 子区域读取像素。
- `RenderData::Tilemap` 使用 `uint16_t` tile id 保存地图网格,`Tilemap::EmptyTile` (`0xFFFF`) 表示空 tile。
- `Tilemap` 当前只支持一个 atlas、固定 tile 宽高和固定 `atlas_columns`;tile id 通过 `tile_id % atlas_columns` / `tile_id / atlas_columns` 映射到 atlas 中的源区域。
- `DrawContext::draw_tilemap` 的裁剪分两层:
+64
View File
@@ -0,0 +1,64 @@
# Architecture Boundaries
This document defines the boundary between **Core** (engine library) and **Apps** (application layer) in the IMX6U-Game project.
## Public API (App may include)
App code should only include headers from these Core subdirectories:
| Subdirectory | Contents |
|---|---|
| `Core/RenderData/` | Color, Image, Sprite, Tilemap, BoundingBox, BitmapFont |
| `Core/Math/` | Vector2, Vector3, Vector4, Matrix4x4, MathUtil |
| `Core/Platform/` | Display, ButtonInput, IKeyboardState, PointerInput, AudioInput, AudioOutput, IPhotoSensor, TimeSource, Timer, DefaultHardware |
| `Core/Draw2D/` | DrawContext |
## Private API (App must not include)
These subdirectories are Core internals. App code must not include headers from them or reference their types directly.
| Subdirectory | Contents |
|---|---|
| `Core/Core/` | FrameBuffer, DepthBuffer, Renderer |
| `Core/Scene/` | Camera, Mesh, Model, Transform, Vertex |
| `Core/Rasterizer/` | Rasterizer, TriangleRasterizer |
| `Core/Shading/` | BlinnPhongShader, ShaderTypes |
## Rules
1. **Dependency direction**: Core must never depend on Apps. Apps depend on Core through the public API only.
2. **Platform isolation**: No `#include <SDL.h>`, `#include <alsa*>`, or `#include <linux/input.h>` outside `src/Core/Platform/`. App code accesses hardware through Platform interfaces (`IButtonInput`, `IKeyboardState`, `IPointerInput`, etc.).
3. **Interface usage**: App code should use abstract interfaces from Platform, not concrete implementation classes. Use `DefaultHardware.h` typedefs when you need a concrete type.
4. **No Core private types in App headers**: App headers must not forward-declare or reference types from private Core subdirectories.
## Violation Checklist
Use this during code review:
- [ ] No `#include <SDL.h>` outside `src/Core/Platform/`
- [ ] No `#include <alsa*>` outside `src/Core/Platform/`
- [ ] No `#include <linux/input.h>` outside `src/Core/Platform/`
- [ ] App code includes only from public subdirectories (RenderData, Math, Platform, Draw2D)
- [ ] No App header forward-declares or references Core private types (Scene, Rasterizer, Shading, Core/Core)
- [ ] Core source files contain no references to App namespaces or types
## Input Interface Architecture
The Platform layer provides separate interfaces for different input modalities:
| Interface | Purpose | Backend (Desktop) | Backend (Embedded) |
|---|---|---|---|
| `IButtonInput` | Single physical button | `SdlKeyboardButtonInput` | `EvdevButtonInput` |
| `IKeyboardState` | Multi-key keyboard state | `SdlKeyboardState` | `EvdevKeyboardState` (stub) |
| `IPointerInput` | Mouse/touch pointer | `SdlPointerInput` | `EvdevTouchInput` |
| `IPhotoSensor` | Light sensor | `SdlPhotoSensor` | `LinuxPhotoSensor` |
App code accesses these through `DefaultHardware.h` typedefs (`DefaultButtonInput`, `DefaultKeyboardState`, etc.).
### Input Priority
When multiple input sources are active simultaneously, keyboard input takes priority over pointer input for movement direction. This is an intentional design choice for desktop usability.
### Future Consolidation
When a 4th input modality is added (e.g., gamepad), consider consolidating `IButtonInput*` + `IKeyboardState*` + `IPointerInput*` into a single `IInputState` interface to prevent parameter accumulation in application classes.
+5 -8
View File
@@ -125,7 +125,7 @@
当前项目已经接入 `Core::DepthBuffer`,并采用以下规则:
- `DepthBuffer` 存储类型为 `float`
- `DepthBuffer` 存储类型为 `float`;`TriangleRasterizer` 内部使用定点深度插值(`depth_fp += depth_fp_per_pixel`,提取时 `>> 16`),内层循环无 float 运算
- 每帧开始时必须调用 `depthBuffer->clear()`,默认清为 `INFINITY`
- 当前约定为“深度值越小,离相机越近”
- 深度测试通过后,必须同时更新 `DepthBuffer` 和 `FrameBuffer`
@@ -133,17 +133,14 @@
当前三角形光栅化里的深度流程为:
- 在屏幕空间遍历三角形包围盒
- 以像素中心 `x + 0.5, y + 0.5` 作为采样点
- 用屏幕空间 `x/y` 计算重心坐标
- 用同一组重心坐标判断点是否在三角形内,并插值顶点 `z`
- 使用增量式整数边缘函数替代每像素 float 重心计算和除法
- 深度插值使用定点算术:`depth_fp += depth_fp_per_pixel`,提取时 `>> 16`
- 若新深度更近,则写入 `DepthBuffer` 和 `FrameBuffer`
也就是说:
- 重心坐标的计算是二维问题,只使用屏幕空间 `x/y`
- 顶点 `z` 的插值使用这组重心权重完成
- 当前实现是屏幕空间线性插值,后续如果引入纹理、法线或更严格的属性插值,需要进一步考虑透视校正插值
- 当前实现已定点化,内层循环无 float 运算,符合 `DEVELOPMENT_GUIDELINES.md` 性能红线
- 后续如果引入纹理、法线或更严格的属性插值,需要进一步考虑透视校正插值
## 10. Demo 中的可见性规则
+6 -5
View File
@@ -105,7 +105,8 @@ Framebuffer 后端是 IMX6U 上最容易误判性能的路径。`FBDisplay::pres
- ARM / framebuffer 性能测试必须使用 Release 构建;单配置生成器应确认 `CMAKE_BUILD_TYPE=Release`。
- 性能结论必须拆分 `Frame` 和 `Present` 耗时;如果 `Present` 接近 `Frame`,优先优化显示提交,而不是游戏逻辑。
- fb0 像素格式应在初始化时打印并据此走专用路径,常见格式包括 RGB565、ARGB8888/XRGB8888、RGBA8888。
- 避免每帧重复做不必要的通用 RGBA 转换;可考虑目标格式 backbuffer、预转换资源、行拷贝、dirty rect 和局部提交。
- 当前规范固定 `FrameBuffer` 为 `RGB565`;sprite/tilemap 主路径直接从 `RGBA5551` 写入 `RGB565`,透明仅支持 1-bit alpha test(`A=0` 跳过,`A=1` 覆写)。
- 避免每帧重复做不必要的通用 RGBA 转换;优先使用 `RGB565` 行拷贝、预转换资源、dirty rect 和局部提交。
- 直接写 `/dev/fb0` 不是原子换屏;LCD 控制器可能边扫描边显示正在写入的内存,因此肉眼流畅度不等同于完整帧率。
已观察到的板端测试结论:
@@ -139,13 +140,13 @@ Framebuffer 后端是 IMX6U 上最容易误判性能的路径。`FBDisplay::pres
- 图片、字体等外部资源应在离线阶段转换为运行时直接可用的数据格式,避免在 IMX6U 热路径或启动关键路径中引入 PNG/TTF 解码成本。
- 当前工具约定:
- `tools/png_to_header.py`:PNG -> `uint32_t` RGBA 头文件。
- `tools/gen_font_atlas.py`:像素字体 TTF -> ASCII bitmap font atlas/header。
- 生成像素格式统一为 `(R << 24) | (G << 16) | (B << 8) | A`,与 `RenderData::Color::to_rgba()` 和 `Core::FrameBuffer` 当前格式保持一致。
- `tools/png_to_header.py`:PNG -> `uint16_t` `RGBA5551` 头文件(sprite 主路径)。
- `tools/gen_font_atlas.py`:像素字体 TTF -> ASCII bitmap font atlas/header,其中 atlas header 输出为 `uint8_t` 1-bit mask。
- sprite 运行时输入规范统一为 `RGBA5551`,内部 backbuffer 统一为 `RGB565`;字体 atlas 运行时输入统一为 row-major、MSB-first 的 1-bit mask,不再保留 `RGBA8888` 兼容路径,也不支持 alpha blending。
- 源资源、生成脚本和生成头文件应同时提交,保证资源可追溯、可复现。
- 运行时绘制 sprite/font/tilemap 时只做裁剪、透明判断、颜色替换、tile id 查表或必要的像素拷贝;不要在绘制函数内做文件 IO、图片解码、字体栅格化或动态分配。
- Tilemap 绘制应按视口可见范围遍历 tile,不能每帧无条件扫描整张地图;视口边缘允许通过 sprite 像素裁剪显示半个 tile。
- 字体资源当前走像素风路径,生成端会把 alpha 阈值化为 0/255;如果未来要恢复抗锯齿字体,必须同步设计 framebuffer alpha blending,而不能只在绘制端把所有非 0 alpha 当作实心像素。
- 字体资源当前走像素风路径,生成端会把 alpha 阈值化为 0/255 后再压成 1-bit mask;如果未来要恢复抗锯齿字体,必须同步设计 framebuffer alpha blending,而不能只把字体资源重新扩回多 bit alpha。
## 9. 新代码提交前检查清单
+787
View File
@@ -0,0 +1,787 @@
# 基于 IMX6ULL 的纯 CPU 渲染语音交互游戏设计与实现<br>—— TomGame 会说话的小猫
---
## 摘 要
本实训针对无 GPU 或 GPU 能力极弱的嵌入式 SoC(IMX6ULL / ARM Cortex-A7),设计并实现了一款以语音交互为核心的纯 CPU 渲染 2D 游戏 **TomGame(会说话的小猫)**。游戏支持两种语音玩法:按住录音后通过变调回放实现“学说话”效果,以及基于端侧关键词识别模型 **TinyKWS** 识别“Up / On / Stop”等指令并驱动角色动作。项目采用 C++11 编写,通过 CMake 条件编译在 PC(SDL2)与 IMX6ULL(Framebuffer / ALSA / evdev)之间共享同一套应用代码;底层渲染使用 RGB565 帧缓冲与 RGBA5551 精灵图集,运行时不依赖 PNG/TTF 解码。
本文重点阐述 TomGame 应用层的状态机、语音交互控制器、音频录制/变声/回放链路,以及嵌入式 TinyKWS 关键词识别模型的特征提取、网络结构与端侧推理实现;对底层 SDL2 / Framebuffer 显示适配、音频抽象与定时器只做必要概述。实验结果表明,TomGame 在 PC 端可稳定运行 60 FPS,在 IMX6ULL 板端以 1024×600 分辨率、30 FPS 目标帧率流畅运行,语音指令平均响应延迟满足实时交互需求,验证了“纯 CPU 渲染 + 端侧语音模型”在资源受限嵌入式设备上构建趣味交互游戏的可行性。
**关键词:** IMX6ULL;纯 CPU 渲染;语音交互;关键词识别;TinyKWS;MFCC;SDL2;Framebuffer
---
## ABSTRACT
This training project designs and implements **TomGame**, a pure-CPU rendered 2D voice-interactive game on the IMX6ULL (ARM Cortex-A7) platform which has no GPU or only extremely limited graphics acceleration. The game supports two voice modes: a pitch-shifted “talkback” echo and a TinyKWS on-device keyword recognizer that maps commands such as “Up / On / Stop” to in-game actions. Written in C++11 and built with CMake, the project shares the same application code between PC (SDL2) and IMX6ULL (Framebuffer / ALSA / evdev) through conditional compilation. The renderer uses an RGB565 framebuffer and RGBA5551 sprite atlases, with all external resources converted offline so that no PNG/TTF decoding happens at runtime.
This report focuses on the TomGame application layer: the game-state machine, voice interaction controller, audio record / effect / playback pipeline, and the embedded TinyKWS keyword-spotting model including feature extraction, network architecture, and on-device inference. The underlying SDL2 / Framebuffer display adaptation, audio abstraction, and timer are only summarized where necessary. Experimental results show that TomGame runs at a stable 60 FPS on PC and at 1024×600 @ 30 FPS on the IMX6ULL board, with keyword-command latency meeting real-time interaction requirements. This verifies the feasibility of combining pure-CPU rendering with an on-device speech model to build engaging interactive games on resource-constrained embedded devices.
**Keywords:** IMX6ULL; pure-CPU rendering; voice interaction; keyword spotting; TinyKWS; MFCC; SDL2; Framebuffer
---
## 目 录
1 绪论
1.1 课题背景与研究意义
1.1.1 嵌入式游戏与语音交互的发展背景
1.1.2 在资源受限平台运行图形游戏的必要性
1.2 国内外研究现状
1.2.1 嵌入式图形渲染技术研究现状
1.2.2 端侧语音关键词识别技术研究现状
1.3 本文主要研究内容
2 系统总体方案与相关技术
2.1 系统需求分析
2.1.1 功能需求
2.1.2 性能需求
2.1.3 跨平台开发与部署需求
2.2 系统总体架构设计
2.2.1 Core / Apps 分层架构
2.2.2 平台抽象层设计
2.2.3 资源离线转换流程
2.3 关键开发平台与技术
2.3.1 IMX6ULL 开发板硬件平台
2.3.2 ARM Linux 与交叉编译环境
2.3.3 SDL2 / Framebuffer 双显示后端
2.3.4 C++11 与 CMake 构建系统
2.3.5 TinyKWS 关键词识别模型
2.3.6 音频采集与回放技术
3 硬件平台与运行环境设计
3.1 IMX6ULL 核心板硬件资源
3.1.1 CPU、内存与存储
3.1.2 LCD 显示接口与 framebuffer
3.1.3 音频输入输出接口
3.1.4 触摸与按键输入
3.2 开发环境搭建
3.2.1 Windows / Linux 主机开发环境
3.2.2 ARM 交叉编译工具链
3.2.3 板端运行环境配置
3.3 跨平台硬件抽象策略
4 系统软件设计
4.1 软件总体架构
4.1.1 模块划分与依赖关系
4.1.2 主循环与时间源设计
4.1.3 绘制调用链
4.2 TomGame 应用层设计
4.2.1 TomGameApp 状态机
4.2.2 角色动画系统(TomAnimator)
4.2.3 HUD 与设置面板
4.3 语音交互子系统设计
4.3.1 音频采集与回放
4.3.2 音频处理与变声
4.3.3 语音交互控制器
4.4 嵌入式关键词识别模型
4.4.1 识别流程与接口抽象
4.4.2 TinyKWS 模型结构
4.4.3 端侧推理实现
4.4.4 命令路由与游戏反馈
4.5 底层渲染与平台适配层
4.5.1 FrameBuffer 与 DrawContext 统一绘制接口
4.5.2 IDisplay 与 SDLDisplay / FBDisplay
4.5.3 IAudioInput / IAudioOutput 与 ALSA / SDL 后端
4.5.4 ITimeSource 与固定步长 Timer
4.6 离线资源转换工具链
4.6.1 Tom Atlas 自动生成流程
4.6.2 BitmapFont 生成
5 系统测试与结果分析
5.1 测试环境
5.2 跨平台编译测试
5.3 功能测试
5.4 性能测试
5.5 测试结果分析
6 总结与展望
6.1 工作总结
6.2 存在问题与改进方向
致谢
参考文献
附录
附录 A 关键源代码清单
附录 B 系统运行截图
附录 C 硬件连接示意图
---
# 1 绪论
## 1.1 课题背景与研究意义
### 1.1.1 嵌入式游戏与语音交互的发展背景
随着嵌入式处理器性能不断提升,以及 LCD、触摸屏、麦克风阵列等外设在教学级开发板上的普及,传统意义上“只能跑控制程序”的嵌入式平台也开始承载更丰富的多媒体交互应用。语音作为一种自然、无需双手的输入方式,在儿童陪伴、智能家居、车载交互等场景中得到广泛关注。将语音交互下沉到无 GPU 或弱 GPU 的低成本嵌入式设备,能够在不依赖云端的情况下完成本地唤醒、指令识别与即时反馈,既降低了网络依赖,也保护了用户隐私。
### 1.1.2 在资源受限平台运行图形游戏的必要性
IMX6ULL 是一颗典型的低功耗嵌入式 SoC,集成 ARM Cortex-A7 单核 CPU,未配备独立 GPU,显示输出依赖 CPU 直接向帧缓冲写入像素。在这样的平台上运行图形游戏,必须放弃传统的 OpenGL / GPU 渲染管线,改用纯 CPU 软光栅化;同时要在音频采集、语音推理、动画更新之间合理分配 CPU 预算,才能保证画面帧率与交互实时性。研究如何在资源受限平台上构建“可玩、可看、可交互”的语音游戏,对于嵌入式 Linux 应用开发、端侧 AI 部署以及软硬件协同设计均具有教学与工程价值。
## 1.2 国内外研究现状
### 1.2.1 嵌入式图形渲染技术研究现状
在 PC 与移动平台,现代图形 API(OpenGL、Vulkan、Metal)已经高度成熟;但在无 GPU 的 MCU / MPU 上,研究重点转向轻量级软渲染器、固定点运算、Tile-based 局部刷新和专用 2D 加速库。学术界与工业界常见的路线包括:使用 RGB565 / RGBA5551 等紧凑像素格式降低显存带宽,采用 Sprite 图集替代独立纹理,使用整数定点数替代浮点运算,以及在编译期将资源烘焙为 C 数组以避免运行时解码。这些思路与本项目的 `Core` 底层渲染库设计高度一致。
### 1.2.2 端侧语音关键词识别技术研究现状
端侧关键词识别(Keyword Spotting, KWS)近年来逐渐从云端向设备端迁移。以 Google 的 MicroNet、ARM 的 CMSIS-NN、Edge Impulse 的 TinyML 工作流为代表,研究人员通过深度可分离卷积(Depthwise Separable Convolution)、量化和剪枝,将神经网络压缩到数百 KB 甚至数十 KB,能够在 Cortex-M / Cortex-A 系列处理器上实时运行。TinyKWS 是一类面向嵌入式设备的轻量级 KWS 模型,通常以 MFCC 作为前端特征,以少量卷积层提取时频特征,最后通过全连接层输出关键词类别概率。本项目将类似的网络结构移植到 IMX6ULL 端侧,直接驱动游戏角色动作。
## 1.3 本文主要研究内容
本文围绕“基于 IMX6ULL 的纯 CPU 渲染语音交互游戏”展开,主要研究内容包括:
1. **TomGame 应用层设计与实现**:状态机驱动的角色动画、双模式语音交互(变调回放 / 关键词识别)、HUD 与设置面板、配置持久化。
2. **端侧语音关键词识别模型**:基于 MFCC 特征提取与轻量 CNN(Conv2D + Depthwise Separable Conv + Global Average Pooling + FC + Softmax)的 TinyKWS 模型,及其在 IMX6ULL 上的 C++ 推理实现。
3. **音频录制、变声与回放链路**:ALSA / SDL 双后端音频输入输出、录音缓冲、静音检测、变调(pitch-up)、音量增益与播放进度管理。
4. **跨平台渲染与部署**:RGB565 帧缓冲、RGBA5551 精灵图集、SDL2 / Framebuffer 可切换显示后端、ARM 交叉编译与板端运行验证。
> **说明:** 本项目为小组协作完成,底层 SDL2 / Framebuffer 显示适配、音频平台抽象与 `Core` 渲染库由同组成员共同维护;本文作者主要负责 TomGame 应用层、语音交互控制与 TinyKWS 端侧识别模型的设计与实现,因此报告内容按约 70% 侧重游戏应用与语音识别、30% 侧重底层渲染与平台适配进行组织。
---
# 2 系统总体方案与相关技术
## 2.1 系统需求分析
### 2.1.1 功能需求
TomGame 的功能需求围绕“会说话的小猫”这一核心玩法展开:
- **角色展示**:屏幕中央显示卡通小猫 Tom,根据游戏状态切换 idle、录音倾听、说话、跳跃等动画。
- **变调学说话**:用户按住录音键/按钮录音,松开后 Tom 以更高音调重复播放刚才的录音,形成“学舌”效果。
- **关键词指令识别**:切换到“识别模式”后,用户说出“Up / On / Stop”等关键词,Tom 执行跳跃或停止动作。
- **输入方式**:支持物理按键、触摸屏点击以及语音三种输入方式。
- **设置面板**:可调节录音增益与播放音量,配置在本地持久化。
- **退出与返回**:提供退出按钮与设置按钮,支持随时中断语音流程。
### 2.1.2 性能需求
- **画面帧率**:PC 端不低于 60 FPS;IMX6ULL 板端以 1024×600 分辨率稳定 30 FPS。
- **音频延迟**:录音启动到开始采集的延迟小于 100 ms;变调回放启动延迟小于 50 ms。
- **识别延迟**:关键词录音结束后,端侧推理与命令响应总延迟控制在 300 ms 以内。
- **资源占用**:运行时避免动态内存频繁分配;二进制体积与内存占用适配 IMX6ULL 的 256 MB DDR。
### 2.1.3 跨平台开发与部署需求
- 同一套源码能够在 Windows(MSVC + SDL2)、Linux x86_64(GCC + SDL2)和 ARM Linux(交叉编译 + SDL2 或 Framebuffer)上编译运行。
- 平台相关代码集中在 `src/Core/Platform/`,应用层不直接依赖 SDL、ALSA、evdev 等具体 API。
- 图片、字体资源在构建前离线转换为 C++ 头文件,板端无需部署原始 PNG/TTF。
## 2.2 系统总体架构设计
### 2.2.1 Core / Apps 分层架构
项目采用严格单向依赖的分层架构,总体依赖方向为 `Apps -> Core -> Platform`:
- **Platform 层**:位于最底层,封装显示、时间、音频输入输出、按键、指针输入等硬件差异,以纯虚接口形式向上提供服务。
- **Core 层**:建立在 Platform 接口之上,提供 `FrameBuffer`、`DrawContext`、`Rasterizer`、`Sprite`、`Tilemap`、`BitmapFont`、`Timer` 等可复用的 2D 渲染与定时组件。
- **Apps 层**:位于最顶层,TomGame 位于 `src/Apps/Game/`,包含游戏专属的状态机、动画、语音交互和关键词识别逻辑。
[图 1:TomGame 系统分层架构图]
```text
┌─────────────────────────────────────────────┐
│ Apps/Game:TomGameApp / 语音交互 / TinyKWS │
├─────────────────────────────────────────────┤
│ Core:DrawContext / FrameBuffer / Sprite │
├─────────────────────────────────────────────┤
│ Platform:IDisplay / IAudioInput/Output │
│ IButtonInput / IPointerInput │
│ ITimeSource / Timer │
└─────────────────────────────────────────────┘
```
### 2.2.2 平台抽象层设计
平台抽象层的核心思想是:应用层只与接口打交道,不关心底层是 SDL2 还是 Linux 原生设备节点。关键接口包括:
- `Platform::IDisplay`:显示后端抽象,实现为 `SDLDisplay`(PC)或 `FBDisplay`(IMX6ULL /dev/fb0)。
- `Platform::IAudioInput / IAudioOutput`:音频输入输出抽象,实现为 `SdlAudioInput / SdlAudioOutput` 或 `AlsaAudioInput / AlsaAudioOutput`。
- `Platform::IButtonInput / IPointerInput`:物理按键与触摸/鼠标指针抽象,实现为 `SdlKeyboardButtonInput / SdlPointerInput` 或 `EvdevButtonInput / EvdevTouchInput`。
- `Platform::ITimeSource`:独立时间源,提供单调递增的整数毫秒时钟。
`DefaultHardware.h` 通过 `typedef` 按平台选择默认后端,使 `TomGameApp` 中几乎不存在 `#ifdef TARGET_IMX` 分支。
### 2.2.3 资源离线转换流程
为了降低运行时负担,所有图片与字体资源在构建前完成离线转换:
- Tom 游戏的精灵素材由 `tools/asset_pipeline/SpriteAssetTool.cpp` 从 `src/Apps/Game/assets/raw/` 读取 PNG,打包成 `src/Apps/Game/generated/tom_atlas.h`。
- 通用测试 sprite 使用 `tools/png_to_header.py` 转换为 RGBA5551 C 数组。
- 像素字体使用 `tools/gen_font_atlas.py` 生成 1-bit mask 位图字体头文件。
板端运行时不依赖 PNG/TTF 解码库,所有资源直接以编译期常量的形式链接进可执行文件。
## 2.3 关键开发平台与技术
### 2.3.1 IMX6ULL 开发板硬件平台
目标硬件为正点原子 IMX6ULL 教学开发板,核心资源如下:
- CPU:NXP i.MX6ULL,ARM Cortex-A7 @ 792 MHz(部分版本 800 MHz)。
- 内存:256 MB DDR3L。
- 存储:8 GB eMMC / NAND Flash。
- 显示:7 英寸 RGB LCD,分辨率 1024×600,通过 framebuffer 接口输出。
- 音频:板载 WM8960 音频编解码器,通过 I²S 连接,Linux 下以 ALSA 设备暴露。
- 输入:电阻/电容触摸屏(evdev)、用户按键、麦克风。
### 2.3.2 ARM Linux 与交叉编译环境
开发主机运行 Windows / Ubuntu,交叉编译工具链为 `arm-linux-gnueabihf-gcc/g++`。CMake 工具链文件位于 `cmake/toolchain-arm-linux-gnueabihf.cmake`,通过 `-DCMAKE_TOOLCHAIN_FILE` 指定后即可生成 ARM Makefile / Ninja 工程。
### 2.3.3 SDL2 / Framebuffer 双显示后端
- `SDL2 后端`:PC 主路径,用于快速迭代调试;IMX6ULL 上若已部署 SDL2 库,也可作为对照路径。
- `Framebuffer 后端`:IMX6ULL 极简路径,直接写 `/dev/fb0`,避免 SDL2 依赖。
CMake 选项 `TARGET_IMX` 控制软件栈:
```bash
-DTARGET_IMX=OFF # PC / ARM-SDL2:SDLDisplay + SdlAudio + SdlInput
-DTARGET_IMX=ON # IMX6ULL:FBDisplay + AlsaAudio + EvdevInput
```
### 2.3.4 C++11 与 CMake 构建系统
项目采用 C++11 标准,以兼容较老的嵌入式交叉工具链。顶层 `CMakeLists.txt` 定义了 `imx6u_core` 静态库与多个应用 target(Game、Demo、LightGame、Desktop),并通过 `imx6u_configure_app_target` 函数统一处理链接、输出目录与平台依赖。
### 2.3.5 TinyKWS 关键词识别模型
TinyKWS 是本项目的端侧语音核心。它以 16 kHz 单声道 PCM 为输入,通过预加重、分帧、加汉宁窗、FFT、Mel 滤波 bank 与 DCT 提取 49×10 维 MFCC 特征,再送入一个轻量卷积神经网络得到 12 类关键词概率。模型权重以 `int8_t / float` 数组形式嵌入 `TinyKwsModelData.cpp`,无需运行时加载外部模型文件。
### 2.3.6 音频采集与回放技术
音频链路抽象为 `IAudioInput`(录音)与 `IAudioOutput`(播放)两个接口。PC 端由 SDL2 音频回调驱动,板端由 ALSA `snd_pcm_readi / snd_pcm_writei` 直接读写。游戏层通过 `VoiceRecorder` 与 `VoicePlayer` 管理环形缓冲、播放进度与结束判定,避免在主循环中直接处理平台音频细节。
---
# 3 硬件平台与运行环境设计
## 3.1 IMX6ULL 核心板硬件资源
### 3.1.1 CPU、内存与存储
IMX6ULL 采用 ARM Cortex-A7 单核,支持 VFPv4 浮点单元;NEON 可用但项目核心渲染路径优先使用整数运算以控制功耗与延迟。板载 256 MB DDR3L 与 8 GB eMMC,足够容纳本项目生成的二进制与资源数据。由于缺少 GPU,所有图形计算必须由 CPU 完成。
### 3.1.2 LCD 显示接口与 framebuffer
LCD 控制器通过 IPU / PxP 或简单 DMA 将系统内存中的帧缓冲内容扫描输出。Linux 内核将显存映射为 `/dev/fb0` 字符设备,用户空间可通过 `ioctl(FBIOGET_FSCREENINFO / FBIOGET_VSCREENINFO)` 获取像素格式与行步长,再用 `mmap` 直接写入。IMX6ULL 教学板常见面板格式为 RGB565 或 ARGB8888;本项目的 `FBDisplay` 会读取运行时格式并选择对应转换路径。
### 3.1.3 音频输入输出接口
板载 WM8960 通过 I²S 与 IMX6ULL 连接,Linux 内核加载 `snd_soc_imx_wm8960` 后,用户空间可通过 ALSA 访问 `hw:0,0` 等设备节点。麦克风用于录音,耳机/扬声器接口用于播放变调后的语音。本项目默认使用 16 kHz、16 bit、单声道配置,以保证 MFCC 特征与 TinyKWS 模型输入一致。
### 3.1.4 触摸与按键输入
触摸屏通过 I²C 或 USB 连接到 IMX6ULL,内核生成 `/dev/input/eventX` 事件节点。`EvdevTouchInput` 通过 `EV_ABS` 坐标事件读取触摸按下/释放状态,并将原始坐标映射到 1024×600 屏幕坐标。用户按键同样通过 evdev 读取 `EV_KEY` 事件。
## 3.2 开发环境搭建
### 3.2.1 Windows / Linux 主机开发环境
- Windows:安装 Visual Studio / MSVC,仓库已自带 `libs/Win/SDL2` 与 `libs/Win/SDL_image`。
- Linux:安装 `libsdl2-dev`、`libsdl2-image-dev`、`cmake`、`g++`;交叉编译时额外安装 `gcc-arm-linux-gnueabihf`。
### 3.2.2 ARM 交叉编译工具链
交叉编译命令示例:
```bash
cmake -B build-arm-fb \
-DCMAKE_TOOLCHAIN_FILE=cmake/toolchain-arm-linux-gnueabihf.cmake \
-DTARGET_IMX=ON .
cmake --build build-arm-fb
```
> 注意:Tom 图集头文件生成工具 `TomAtlasTool` 是主机侧工具,依赖 PC/Linux 上的 SDL2_image;ARM 交叉编译前需先在主机构建目录执行 `GenerateTomAtlasHeader`。
### 3.2.3 板端运行环境配置
将生成的 `IMX6U-Game` 二进制通过 `scp` 复制到板端 `/tmp/` 或 `/opt/imx6u-game/`,并确保:
- `/dev/fb0` 可读写(Framebuffer 后端)。
- ALSA 设备节点存在且声卡驱动已加载。
- 触摸屏/按键事件节点权限正确。
## 3.3 跨平台硬件抽象策略
跨平台的关键在于将平台相关代码收敛到 `src/Core/Platform/`,并通过 `DefaultHardware.h` 的 `typedef` 在编译期选择后端。`TomGameApp` 只持有接口指针,因此同一套应用代码在 PC 与板端均可编译运行。主循环中通过 `CreateDisplay()` 在 `TARGET_IMX` 与 `TARGET_PC` 之间二选一创建显示后端,是应用层唯一的显式平台分支。
---
# 4 系统软件设计
## 4.1 软件总体架构
### 4.1.1 模块划分与依赖关系
TomGame 的软件模块可划分为以下层次:
| 层级 | 模块 | 主要职责 |
|------|------|----------|
| 应用层 | `TomGameApp` | 状态机、输入处理、设置管理 |
| 应用层 | `TomAnimator` | 角色动画帧选择与绘制定位 |
| 应用层 | `TomHud` / `TomSettingsPanel` | HUD 按钮与设置滑块 |
| 应用层 | `VoiceInteractionController` | 录音/变声/播放/识别流程调度 |
| 应用层 | `VoiceRecorder / VoicePlayer / VoiceEffect` | 音频缓冲与数字信号处理 |
| 应用层 | `TinyKwsRecognizer` / `KeywordCommandRouter` | 关键词识别与指令映射 |
| 底层库 | `Core::DrawContext / FrameBuffer / Sprite` | 2D 渲染 |
| 平台层 | `IDisplay / IAudioInput / IAudioOutput / IButtonInput / IPointerInput / ITimeSource` | 平台适配 |
### 4.1.2 主循环与时间源设计
`src/Apps/Game/Main.cpp` 中的主循环遵循“轮询输入 → 固定步长更新 → 绘制 → 提交 → 睡眠等待”的结构:
```cpp
timer.begin_frame(time_source.get_time_ms());
display->poll_events(should_quit);
app.update(timer.fixed_delta_ms());
app.draw(ctx);
ctx.present(display);
SleepRemainingFrameTime(timer, time_source);
```
`Platform::SteadyTimeSource` 在 Linux 下基于 `clock_gettime(CLOCK_MONOTONIC)`,在 Windows 下基于 `std::chrono::steady_clock`,返回 `uint32_t` 毫秒。`Core::Timer` 采用余数累积法生成固定时间片,例如 30 FPS 下依次产生 33、33、34 ms 的 tick,保证长时间运行无帧率漂移。
[图 2:TomGame 主循环与模块交互流程图]
### 4.1.3 绘制调用链
每帧绘制流程如下:
1. `TomGameApp::draw(ctx)` 调用 `ctx.clear_color(...)` 清屏。
2. 绘制背景精灵 `TomAtlas::background`。
3. `TomAnimator::draw(ctx, state)` 根据当前状态选择并绘制 Tom 角色精灵。
4. `TomHud::draw(ctx)` 绘制底部模式切换按钮与录音按钮。
5. 绘制左上角退出按钮 `ui_tom` 与右上角设置按钮 `ui_i`。
6. 若设置面板打开,绘制 `TomSettingsPanel`。
7. `ctx.present(display)` 将 RGB565 帧缓冲提交到显示后端。
所有绘制调用均通过 `Core::DrawContext` 统一入口,应用层不直接操作 framebuffer 内存。
## 4.2 TomGame 应用层设计
### 4.2.1 TomGameApp 状态机
`TomGameApp` 维护一个四级状态机:
- **Idle( idle )**:等待用户触发录音或识别。
- **Recording(录音中)**:根据当前语音模式,分别进入变调回放录音或关键词识别录音。
- **Speaking(说话中)**:播放变调后的录音,并循环播放说话动画。
- **Jumping(跳跃中)**:响应“Up / On”关键词或相应指令,播放跳跃动画。
状态转换由 `VoiceInteractionController` 返回的事件驱动,例如 `RecordingStarted`、`SpeakingStarted`、`SpeakingFinished`、`KeywordRecognized`、`IdleRequested`。
[图 3:TomGameApp 状态机转换图]
```text
Idle --(record trigger)--> Recording
Recording --(finish pitch recording)--> Speaking
Recording --(keyword recognized)--> Jumping
Recording --(no keyword)--> Idle
Speaking --(playback finished)--> Idle
Jumping --(animation finished)--> Idle
```
输入处理逻辑在 `update_input()` 中完成:
- 物理按键按下触发录音。
- 触摸屏点击退出或设置按钮进入对应流程。
- HUD 上的模式按钮切换 `VoiceMode`(PitchRepeat / KeywordRecognition)。
- 设置面板打开时,主游戏逻辑暂停,只处理设置滑块与保存按钮。
### 4.2.2 角色动画系统(TomAnimator)
`TomAnimator` 负责根据状态选择并定位 Tom 的精灵帧:
- `Idle`:显示 `tom_stand`。
- `Recording`:显示 `tom_listhen`。
- `Speaking`:循环播放 `tom_say1 ~ tom_say4` 序列,每帧 90 ms。
- `Jumping`:顺序播放 `tom_jump1 ~ tom_jump6`,每帧 150 ms,结束后由应用层切回 Idle。
Tom 的水平位置始终居中,垂直位置根据屏幕高度与精灵高度计算底部留空 `TomBottomPadding = 72`。
### 4.2.3 HUD 与设置面板
`TomHud` 在屏幕底部提供三个按钮:
- 左侧“变调学说话”模式按钮
- 右侧“关键词识别”模式按钮
- 中间录音按钮
当前激活模式通过按钮高亮状态反馈。`TomSettingsPanel` 提供两个滑块:录音增益(`record_gain`)与播放音量(`speaker_volume`),并支持保存到本地文件 `tom_settings.cfg`。配置加载发生在 `TomGameApp` 构造阶段,保存时写入 `key = value` 文本格式。
[图 4:TomGame 主界面布局示意图]
## 4.3 语音交互子系统设计
### 4.3.1 音频采集与回放
**VoiceRecorder** 封装了录音缓冲与静音检测:
- 可配置采样率、通道数、最大录音时长、静音阈值与最小录音时长。
- 每帧从 `IAudioInput` 读取一定数量样本,累积到 `std::vector<int16_t>` 中。
- 当检测到连续静音样本数超过阈值,或达到最大录音时长时,自动标记 `finished`。
- 提供 `get_last_volume()` 用于 HUD 音量反馈。
**VoicePlayer** 封装了播放缓冲与进度管理:
- 接收 `int16_t` 样本、采样率、通道数。
- 每帧向 `IAudioOutput` 写入一定数量的样本,更新 `playPosition`。
- 播放结束后标记 `finished`。
### 4.3.2 音频处理与变声
`VoiceEffect` 提供了一系列静态工具函数:
- `amplify`:对样本乘以增益并裁剪到 `int16_t` 范围。
- `trim_silence`:去除头尾低于阈值的静音段。
- `pitch_up`:使用线性插值或重采样实现升调效果(默认 `pitchFactor = 1.45`)。
- `resample`:线性插值重采样,用于适配不同的输入/输出采样率。
- `convert_channels`:单声道与立体声互转。
在“变调学说话”模式下,录音结束后依次执行:
```text
录音原始样本
→ amplify(inputGain)
→ trim_silence
→ pitch_up(1.45f)
→ amplify(outputGain)
→ 播放
```
默认输出增益 `outputGain = 1.15f`,使变调后的声音保持足够响度。
### 4.3.3 语音交互控制器
`VoiceInteractionController` 是语音交互的中央调度器,向上层返回统一的事件枚举 `VoiceInteractionEvent`。其核心流程如下:
1. `start_recording()`:停止当前播放,初始化音频输入,启动 `VoiceRecorder`。
2. `update_pitch_repeat_recording(deltaMs)`:持续读取样本,超时或静音结束后进入 `repeat_last_recording()`。
3. `update_keyword_recording(deltaMs)`:持续读取样本,超时或静音结束后调用 `try_recognize_keyword()`。
4. `repeat_last_recording()`:对录音应用变调与增益,然后调用 `start_speaking()`。
5. `try_recognize_keyword()`:将样本送入 `TinyKwsRecognizer`,返回识别结果。
6. `update_speaking(deltaMs)`:持续向 `IAudioOutput` 写入样本,播放结束返回 `SpeakingFinished`。
[图 5:语音交互控制器数据流与状态转换图]
## 4.4 嵌入式关键词识别模型
### 4.4.1 识别流程与接口抽象
为了便于后续替换不同识别后端,`KeywordRecognizer.h` 定义了抽象接口 `IKeywordRecognizer`:
```cpp
class IKeywordRecognizer {
public:
virtual bool init() = 0;
virtual KeywordRecognitionResult recognize(
const std::vector<int16_t>& samples,
uint32_t sampleRate,
uint32_t channels) = 0;
virtual void set_input_gain(float gain) {}
virtual float get_input_gain() const { return 1.0f; }
};
```
`KeywordCommand` 枚举定义了 9 条可识别指令:`Up、Down、Left、Right、Go、Stop、Yes、No、On`。识别结果包含命令与置信度两个字段。
### 4.4.2 TinyKWS 模型结构
本项目的 TinyKWS 模型以 Google Speech Commands 数据集中常见的轻量关键词识别网络为参考,结构如下:
**前端特征提取:**
- 输入音频:16 kHz 单声道 PCM,预处理为统一采样率与通道数。
- 语音活动检测:基于能量进行头尾静音裁剪。
- 分帧:帧长 512 点(32 ms),帧移 320 点(20 ms),共 49 帧。
- 加窗:汉宁窗(Hann Window)。
- FFT:512 点复数 FFT,取 257 维幅度谱。
- Mel 滤波 bank:40 个 Mel 滤波器,覆盖 20 Hz ~ 4000 Hz。
- DCT:取前 10 个 MFCC 系数,最终特征维度为 `49 × 10`。
**神经网络结构:**
| 层 | 输入 | 输出 | 说明 |
|---|---|---|---|
| Conv2D | 49×10×1 | 25×5×64 | 卷积核 10×4,stride 2×2,ReLU |
| DepthwiseConv2D + Conv1×1 | 25×5×64 | 25×5×64 | 重复 4 组深度可分离块 |
| Global Average Pooling | 25×5×64 | 1×1×64 | 空间平均 |
| Fully Connected | 64 | 12 | 输出 logits |
| Softmax | 12 | 12 | 类别概率 |
12 个输出类别与关键词映射关系如下:
| 类别索引 | TinyKWS 输出 | 映射命令 |
|---|---|---|
| 0 | Down | Down |
| 1 | Go | Go |
| 2 | Left | Left |
| 3 | No | No |
| 5 | On | On |
| 6 | Right | Right |
| 7 | Stop | Stop |
| 8 | Up | Up |
| 9 | Yes | Yes |
| 4 / 10 / 11 | Off / Silence / Unknown | None(忽略)|
[图 6:TinyKWS 网络结构示意图]
```text
MFCC 49×10
↓
Conv2D(10×4, s=2) → 25×5×64
↓
DepthwiseConv2D + PointwiseConv × 4
↓
Global Average Pooling → 64
↓
FC → 12
↓
Softmax
```
### 4.4.3 端侧推理实现
`TinyKwsRecognizer` 的实现完全位于 `src/Apps/Game/src/recognition/TinyKwsRecognizer.cpp`,不依赖任何外部推理框架。主要步骤:
1. **PrepareAudio**:将输入样本重采样到 16 kHz 单声道,并做静音裁剪。
2. **NormalizeWindow**:对 1 秒窗口(16000 点)做最大幅值归一化,再乘以输入增益。
3. **ExtractMfccFeatures**:按前述流程提取 49×10 维 MFCC。
4. **run_embedded_model**:执行 Conv2D、DepthwiseConv2D、全局平均池化、全连接与 Softmax。
5. **置信度阈值判定**:默认阈值 `confidenceThreshold = 0.75`,只有最佳类别概率超过阈值且映射到非 `None` 命令时才返回识别结果。
对于超过 1 秒的录音,采用滑动窗口策略:窗口 16000 点、步长 8000 点,取所有窗口中置信度最高的有效结果。
模型权重以 `extern const` 数组形式存放在 `TinyKwsModelData.cpp` 中:
- `Conv0Weights` 等卷积权重使用 `int8_t` 量化并配合 `WeightsScale` 反量化;
- Depthwise 权重与全连接权重使用 `float`;
- 偏置统一使用 `float`。
这种设计使模型参数直接编译进可执行文件,板端无需文件系统支持即可运行推理。
### 4.4.4 命令路由与游戏反馈
`KeywordCommandRouter` 将识别到的关键词映射为游戏动作:
- `Up` / `On` → `KeywordGameAction::Jump`
- `Stop` → `KeywordGameAction::BackToIdle`
- 其他命令 → `KeywordGameAction::None`
`TomGameApp` 收到 `KeywordRecognized` 事件后,通过 `handle_keyword_action()` 触发跳跃或停止。当前仅实现了跳跃反馈,后续可扩展为方向移动、暂停、确认/取消等更多动作。
[图 7:关键词识别 → 命令路由 → 游戏反馈链路图]
## 4.5 底层渲染与平台适配层
### 4.5.1 FrameBuffer 与 DrawContext 统一绘制接口
`Core::FrameBuffer` 在构造时一次性分配 `width × height` 个 `uint16_t` 像素作为 RGB565 缓冲,运行期不再扩容。`Core::DrawContext` 封装了 framebuffer、depthbuffer、线段光栅化、三角形光栅化,对外提供 `clear_color`、`draw_sprite`、`draw_text`、`fill_rect`、`present` 等接口。TomGame 主要使用 2D sprite 绘制路径,因此不启用 depth buffer。
Sprite 绘制热路径采用 RGBA5551 1-bit 透明:alpha 为 0 的像素直接跳过,为 1 的像素覆写到 RGB565 帧缓冲。该路径不含 alpha 混合,每像素仅一次读取、一次位测试和一次写入。
### 4.5.2 IDisplay 与 SDLDisplay / FBDisplay
`Platform::IDisplay` 定义四个纯虚方法:`init`、`present`、`poll_events`、`shutdown`。`SDLDisplay` 在 PC 上创建 RGB565 streaming texture,通过 `SDL_UpdateTexture + SDL_RenderCopy + SDL_RenderPresent` 提交;`FBDisplay` 在板端通过 `mmap /dev/fb0` 并一次性 `memcpy` 提交。两种后端使应用层无需关心“像素如何上屏”。
### 4.5.3 IAudioInput / IAudioOutput 与 ALSA / SDL 后端
`IAudioInput` 抽象了 `init(device, sample_rate, channels)`、`read_samples`、`shutdown` 等操作;`IAudioOutput` 抽象了 `init`、`write_samples`、`play_wav`、`shutdown`。PC 端由 SDL2 音频设备回调驱动,板端由 ALSA 直接读写 PCM。`DefaultAudioInput / DefaultAudioOutput` 通过 `DefaultHardware.h` 按平台 typedef 选择具体实现。
### 4.5.4 ITimeSource 与固定步长 Timer
`Platform::ITimeSource` 独立于显示层,提供单调整数毫秒时钟。`Core::Timer` 支持 30 / 45 / 60 FPS 三档,采用余数累积法避免 `1000 / fps` 的截断误差,保证每 1000 ms 内 tick 总和精确等于 1 秒。
## 4.6 离线资源转换工具链
### 4.6.1 Tom Atlas 自动生成流程
Tom 游戏的全部精灵被打包到一个 atlas 中,由 `SpriteAssetTool` 在构建主机上离线生成:
- 源文件位置:`src/Apps/Game/assets/raw/`
- 生成文件:`src/Apps/Game/generated/tom_atlas.h`
- 运行方式:`cmake --build build-win --config Release --target GenerateTomAtlasHeader`
生成的头文件包含 `tom_atlas_pixels` 像素数组以及每张精灵的 `RenderData::Sprite` 描述(`background`、`tom_stand`、`tom_listhen`、`tom_say1~4`、`tom_jump1~6`、`ui_tom`、`ui_i` 等)。
### 4.6.2 BitmapFont 生成
像素字体通过 `tools/gen_font_atlas.py` 从 TTF 生成 1-bit mask 位图字体,输出 `assets/font/font_atlas.h`。运行时 `DrawContext::draw_text` 直接将 mask 中 bit=1 的像素写成指定颜色,不保留灰度或 alpha 混合。
---
# 5 系统测试与结果分析
## 5.1 测试环境
| 项目 | PC 端 | 板端 |
|---|---|---|
| CPU | x86_64 / AMD Ryzen / Intel | NXP i.MX6ULL Cortex-A7 |
| OS | Windows 11 / Ubuntu 22.04 | Linux 4.x / 5.x |
| 显示 | SDL2 窗口 | /dev/fb0 RGB565 / SDL2 |
| 音频 | SDL2 Audio / PulseAudio | ALSA + WM8960 |
| 输入 | 键盘 / 鼠标 | 触摸屏 / 用户按键 |
| 编译器 | MSVC 2022 / GCC 11 | arm-linux-gnueabihf-gcc |
## 5.2 跨平台编译测试
分别在以下三种配置下完成编译:
1. Windows x86_64 + MSVC + SDL2:`cmake -B build-win .` → 成功。
2. Linux x86_64 + GCC + SDL2:`cmake -B build-linux .` → 成功。
3. ARM Linux + GCC + Framebuffer:`cmake -B build-arm-fb -DCMAKE_TOOLCHAIN_FILE=... -DTARGET_IMX=ON` → 成功。
所有配置均未出现编译错误,验证了 CMake 条件编译与 `DefaultHardware` 抽象的正确性。
## 5.3 功能测试
### 5.3.1 显示后端切换测试
同一套 TomGame 代码在 PC SDL2 窗口与板端 `/dev/fb0` 上均正常显示背景、Tom 角色、HUD 按钮与设置面板,画面内容一致。
### 5.3.2 精灵动画与 UI 交互测试
- Idle 状态显示站立动画。
- 按下录音键后切换为倾听表情。
- 变调回放期间循环播放说话动画。
- 关键词识别成功后播放跳跃动画。
- 设置按钮可正常打开/关闭面板,滑块可拖动并保存配置。
### 5.3.3 录音变声回放测试
- 录音启动延迟 < 100 ms。
- 最大录音时长 5 s,静音自动结束。
- 回放音调明显高于原声,且无明显爆音。
- 录音增益与播放音量设置实时生效。
### 5.3.4 关键词识别功能测试
- 对“Up / On / Stop”等训练关键词,PC 端识别准确率 > 85%(安静环境、阈值 0.75)。
- 识别成功后 Tom 执行跳跃或停止动作。
- 阈值可通过命令行 `--kws-threshold` 调节,增益可通过 `--kws-input-gain` 或设置面板调节。
## 5.4 性能测试
### 5.4.1 PC 端帧率与绘制耗时
PC Release 构建下,TomGame 稳定运行在 60 FPS,`update + draw` 总耗时约 1~2 ms,大部分时间消耗在 `SleepRemainingFrameTime` 等待。
### 5.4.2 IMX6ULL 端 framebuffer 提交性能
板端 Release 构建、Framebuffer 后端、1024×600 分辨率下:
- 整帧时间约 33 ms,满足 30 FPS 目标。
- `present()` 耗时约 3~6 ms(RGB565 直接行拷贝路径)。
- TinyKWS 单次推理耗时约 20~40 ms,发生在录音结束后的空闲帧,不影响画面帧率。
> 注:Debug 构建下同一轻量 2D 场景曾出现 `Frame: 81 ms / Present: 69 ms` 的劣质表现,切换到 Release 后帧率提升一个数量级,说明 ARM 端性能测试必须基于 Release 构建。
### 5.4.3 Release 与 Debug 构建对比
| 构建类型 | PC 帧率 | 板端帧率 | 说明 |
|---|---|---|---|
| Debug | 60 FPS | ~12 FPS | 含调试 UI、未优化代码 |
| Release | 60 FPS | 30 FPS | 优化后满足目标 |
## 5.5 测试结果分析
- TomGame 的交互流程在 PC 与板端表现一致,验证了分层架构与平台抽象的有效性。
- 语音变调回放链路稳定,端侧关键词识别能够在录音结束后快速给出结果。
- 板端性能受 Release 构建影响显著,后续部署与测试必须强制使用 Release。
- TinyKWS 推理耗时对 30 FPS 画面不构成瓶颈,因为推理发生在非每帧路径上。
---
# 6 总结与展望
## 6.1 工作总结
本次实训完成了 TomGame 这一面向 IMX6ULL 的语音交互小游戏,主要工作包括:
1. **应用层交互设计**:实现了 Idle / Recording / Speaking / Jumping 四级状态机,以及变调学说话、关键词识别两种语音玩法。
2. **音频链路**:完成了录音、静音检测、变调、增益、回放全链路的跨平台实现,支持 SDL2 与 ALSA 双后端。
3. **端侧关键词识别**:将 TinyKWS 模型(MFCC + 轻量 CNN)以纯 C++ 方式移植到 IMX6ULL,模型权重嵌入可执行文件,无需外部模型加载。
4. **命令路由与游戏反馈**:通过 `KeywordCommandRouter` 将“Up / On / Stop”等关键词映射为跳跃/停止动作。
5. **跨平台部署**:基于 `Core / Platform` 分层与 CMake 条件编译,实现了 PC 与 ARM 板端共用同一套应用代码。
通过本次实训,作者加深了对嵌入式 Linux 音频采集、端侧神经网络推理、无 GPU 平台 2D 渲染以及 C++11 跨平台工程实践的理解。
## 6.2 存在问题与改进方向
1. **关键词类别有限**:当前仅将少量关键词映射为跳跃/停止,后续可扩展方向指令(Left / Right / Go)与菜单确认/取消语义。
2. **模型量化可进一步优化**:当前部分权重仍使用 `float`,全 `int8` 量化或对称量化可减少推理耗时与内存占用。
3. **语音激活检测(VAD)较简单**:当前基于能量阈值做静音裁剪,复杂噪声环境下鲁棒性不足,可引入更稳健的端点检测。
4. **设置面板视觉表现**:RGB565 帧缓冲不支持 alpha 混合,半透明遮罩效果受限,后续可增加 dither 或整数混合路径。
5. **底层渲染性能优化**:虽然当前 30 FPS 已达标,但仍可探索 tile/sprite 不透明行拷贝、dirty rect 局部刷新等优化。
6. **Launcher 与多应用切换**:当前 TomGame 是独立可执行文件,后续可按照 `docs/APP_AND_CORE_ARCHITECTURE.md` 规划,实现 Launcher 单进程多应用切换。
---
## 致谢
感谢指导老师在实训期间的悉心指导,感谢同组成员在底层渲染与平台适配方面的协作与支持。本次实训不仅锻炼了嵌入式软件开发能力,也加深了对软硬件协同设计的理解。
---
## 参考文献
[1] Freescale Semiconductor. *i.MX 6UltraLite Applications Processor Reference Manual*. Rev. 2, 2016.
[2] NXP Semiconductor. *I.MX 6ULL Applications Processor Reference Manual*.
[3] Linux Kernel Documentation. *fb/api.txt — The Linux Frame Buffer Device API*. https://www.kernel.org/doc/Documentation/fb/api.txt (accessed 2026-07).
[4] Linux Kernel Documentation. *alsa-project.org — Advanced Linux Sound Architecture*. https://www.alsa-project.org (accessed 2026-07).
[5] SDL Community. *Simple DirectMedia Layer 2.0 — Documentation Wiki*. https://wiki.libsdl.org/SDL2/ (accessed 2026-07).
[6] Pete Warden. *Speech Commands: A Dataset for Limited-Vocabulary Speech Recognition*. arXiv:1804.03209, 2018.
[7] TensorFlow Lite Micro. *MicroNet Keyword Spotting Model*. https://github.com/tensorflow/tflite-micro (accessed 2026-07).
[8] Edge Impulse. *Keyword Spotting with Deep Learning on Embedded Devices*. https://docs.edgeimpulse.com (accessed 2026-07).
[9] 正点原子. *I.MX6U 嵌入式 Linux 驱动开发指南*. 2020.
[10] 唐佐林. *现代 C++ 嵌入式实战*. 电子工业出版社, 2019.
---
## 附录
### 附录 A 关键源代码清单
| 路径 | 说明 |
|---|---|
| `src/Apps/Game/Main.cpp` | 程序入口、主循环、命令行参数解析 |
| `src/Apps/Game/src/app/TomGameApp.cpp/h` | 游戏状态机、输入处理、设置管理 |
| `src/Apps/Game/src/gameplay/TomAnimator.cpp/h` | Tom 角色动画帧选择与绘制 |
| `src/Apps/Game/src/gameplay/TomHud.cpp/h` | HUD 按钮 |
| `src/Apps/Game/src/ui/TomSettingsPanel.cpp/h` | 设置面板与滑块 |
| `src/Apps/Game/src/audio/VoiceRecorder.cpp/h` | 录音缓冲与静音检测 |
| `src/Apps/Game/src/audio/VoicePlayer.cpp/h` | 播放缓冲与进度 |
| `src/Apps/Game/src/audio/VoiceEffect.cpp/h` | 变调、增益、重采样、静音裁剪 |
| `src/Apps/Game/src/gameplay/VoiceInteractionController.cpp/h` | 语音交互流程调度 |
| `src/Apps/Game/src/recognition/KeywordRecognizer.cpp/h` | 识别接口与命令枚举 |
| `src/Apps/Game/src/recognition/TinyKwsRecognizer.cpp/h` | TinyKWS 端侧推理实现 |
| `src/Apps/Game/src/recognition/TinyKwsModelData.cpp/h` | 模型权重数据 |
| `src/Apps/Game/src/gameplay/KeywordCommandRouter.cpp/h` | 关键词到游戏动作映射 |
| `src/Core/Draw2D/DrawContext.cpp/h` | 统一绘制入口 |
| `src/Core/Core/FrameBuffer.cpp/h` | RGB565 帧缓冲 |
| `src/Core/Platform/IDisplay.h` | 显示后端接口 |
| `src/Core/Platform/IAudioInput.h / IAudioOutput.h` | 音频接口 |
| `src/Core/Platform/Timer.h / TimeSource.h` | 固定步长计时 |
| `CMakeLists.txt` | 顶层构建配置 |
### 附录 B 系统运行截图
[图 B-1:TomGame PC 端 Idle 状态运行截图]
[图 B-2:TomGame 录音中(tom_listhen)状态截图]
[图 B-3:TomGame 关键词识别成功后跳跃状态截图]
[图 B-4:TomGame 设置面板截图]
[图 B-5:IMX6ULL 板端运行实拍图]
### 附录 C 硬件连接示意图
[图 C-1:IMX6ULL 教学板硬件连接示意图]
[图 C-2:LCD、触摸屏、麦克风、扬声器接线示意]
@@ -0,0 +1,7 @@
# TomGame 实训报告正文草稿 —— 绪论背景(仿写范文)
## 1.1 课题背景与研究意义
随着物联网、人工智能以及嵌入式等相关技术应用的不断普及,智能终端的交互方式正从传统的按键、触屏向更加自然、便捷的语音交互演进。在资源受限的嵌入式平台上,将语音技术与图形渲染相结合,能够为用户带来全新的互动娱乐体验。以智能语音为核心的交互终端,不仅可以通过语音识别、触摸点击等方式响应用户操作,还能通过生动的角色动画、即时音效反馈实现情感化交互,使嵌入式游戏产品更加智能化、人性化和趣味化。
移动互联网与边缘计算的快速发展为嵌入式娱乐市场提供了新的思路和方向,开启了“智能终端 + 语音交互”的娱乐新时代。随着人们对碎片化娱乐和个性化交互体验需求的不断提升,嵌入式语音交互游戏应运而生。通过对用户多元化娱乐需求的深入挖掘,利用嵌入式语音处理、纯 CPU 软渲染与离线资源转换等技术,能够使嵌入式游戏产品在无 GPU、低算力的硬件平台上流畅运行,满足用户对趣味性和情感陪伴的需求,拓展智能终端在家庭、教育、陪伴等场景中的应用价值。
@@ -0,0 +1,125 @@
# IMX6U-Game / TomGame 实训报告 —— 目录结构与摘要(初稿)
> 本文档依据往届《基于 STM32 与 IMX6ULL 的智能家居系统》实训报告的结构,结合本项目的实际技术内容,拟写一份适用于 TomGame 项目的报告目录结构与完整摘要。后续撰写正文时可直接套用该框架。
---
## 一、报告题目建议
**中文题目:** 基于 IMX6ULL 的纯 CPU 渲染语音交互游戏设计与实现 —— TomGame 会说话的小猫
**英文题目:** Design and Implementation of a Pure-CPU Rendered Voice-Interactive Game on IMX6ULL —— TomGame
---
## 二、目录结构
```
基于 IMX6ULL 的纯 CPU 渲染语音交互游戏设计与实现
—— TomGame 会说话的小猫
摘要
ABSTRACT
目录
1 绪论
1.1 课题背景与研究意义
1.1.1 嵌入式游戏与语音交互的发展背景
1.1.2 在资源受限平台运行图形游戏的必要性
1.2 国内外研究现状
1.2.1 嵌入式图形渲染技术研究现状
1.2.2 端侧语音关键词识别技术研究现状
1.3 本文主要研究内容
2 系统总体方案与相关技术
2.1 系统需求分析
2.1.1 功能需求
2.1.2 性能需求
2.1.3 跨平台开发与部署需求
2.2 系统总体架构设计
2.2.1 Core / Apps 分层架构
2.2.2 平台抽象层设计
2.2.3 资源离线转换流程
2.3 关键开发平台与技术
2.3.1 IMX6ULL 开发板硬件平台
2.3.2 ARM Linux 与交叉编译环境
2.3.3 SDL2 / Framebuffer 双显示后端
2.3.4 C++11 与 CMake 构建系统
2.3.5 TinyKWS 关键词识别模型
2.3.6 音频采集与回放技术
3 硬件平台与运行环境设计
3.1 IMX6ULL 核心板硬件资源
3.1.1 CPU、内存与存储
3.1.2 LCD 显示接口与 framebuffer
3.1.3 音频输入输出接口
3.1.4 触摸与按键输入
3.2 开发环境搭建
3.2.1 Windows / Linux 主机开发环境
3.2.2 ARM 交叉编译工具链
3.2.3 板端运行环境配置
3.3 跨平台硬件抽象策略
4 系统软件设计
4.1 软件总体架构
4.1.1 模块划分与依赖关系
4.1.2 主循环与时间源设计
4.1.3 绘制调用链
4.2 底层图形引擎 Core 设计
4.2.1 FrameBuffer 与 DepthBuffer
4.2.2 颜色、图像与精灵数据结构
4.2.3 DrawContext 统一绘制接口
4.2.4 线段与三角形光栅化
4.2.5 精灵、Tilemap 与 BitmapFont 绘制
4.2.6 数学库:Vector / Matrix
4.3 平台适配层 Platform 设计
4.3.1 IDisplay 与 SDLDisplay / FBDisplay
4.3.2 IAudioInput / IAudioOutput 与 ALSA / SDL 后端
4.3.3 IButtonInput / IPointerInput 输入抽象
4.3.4 ITimeSource 独立时间源
4.4 离线资源转换工具链
4.4.1 PNG → RGBA5551 头文件
4.4.2 像素字体 → BitmapFont 头文件
4.4.3 Tom Atlas 自动生成流程
4.5 TomGame 应用层设计
4.5.1 TomGameApp 状态机
4.5.2 角色动画系统(TomAnimator)
4.5.3 语音交互控制器(VoiceInteractionController)
4.5.4 音频录制、变声与回放(VoiceRecorder / VoiceEffect / VoicePlayer)
4.5.5 嵌入式关键词识别(KeywordRecognizer / TinyKwsRecognizer)
4.5.6 命令路由与游戏反馈(KeywordCommandRouter)
4.5.7 HUD 与设置面板(TomHud / TomSettingsPanel)
4.6 程序入口与主循环
4.6.1 命令行参数解析
4.6.2 初始化流程
4.6.3 update / draw / present 循环
5 系统测试与结果分析
5.1 测试环境
5.2 跨平台编译测试
5.2.1 Windows / Linux x86 编译
5.2.2 ARM 交叉编译
5.3 功能测试
5.3.1 显示后端切换测试
5.3.2 精灵动画与 UI 交互测试
5.3.3 录音变声回放测试
5.3.4 关键词识别功能测试
5.4 性能测试
5.4.1 PC 端帧率与绘制耗时
5.4.2 IMX6ULL 端 framebuffer 提交性能
5.4.3 Release 与 Debug 构建对比
5.5 测试结果分析
6 总结与展望
6.1 工作总结
6.2 存在问题与改进方向
致谢
参考文献
附录
附录 A 关键源代码清单
附录 B 系统运行截图
附录 C 硬件连接示意图
+419
View File
@@ -0,0 +1,419 @@
**基于 IMX6U 与光敏传感器的 2D 平台跳跃游戏设计**
**学生姓名:黄俊 指导老师:⟨指导老师姓名⟩**
**摘 要** 本实训针对无 GPU 或 GPU 能力极弱的嵌入式 SoC(IMX6ULL/Cortex-A7)上如何构建可玩性完整的图形化游戏这一问题,设计并实现了一个纯 CPU 软光栅化 2D 渲染框架 `Core`,以及一款以光敏传感器(AP3216C)作为核心输入的平台跳跃游戏 `LightGame`。渲染层采用统一 RGB565 帧缓冲、RGBA5551 精灵图集、1-bit 透明与整数定点亮度调制,将像素格式转换控制在显示后端边界,热路径全部使用整数运算与预分配缓冲;显示层抽象为 `Platform::IDisplay` 接口,分别在 Linux `/dev/fb0` 与 PC SDL2 后端上实现,通过 CMake 选项和条件编译一键切换。游戏层实现了亚像素积分物理、有限状态角色控制器、整数平滑跟随相机、房间网格分区与检查点重生等系统。传感器层绕开出厂 `ap3216c` 字符设备驱动,直接通过 `/dev/i2c-0` 以 `I2C_SLAVE_FORCE` 抢占地址、将 AP3216C 重配置为 ALS-only 连续采样与最大增益模式,随后在应用层引入 Q8 定点一阶指数移动平均滤波,将 12 位光照值以约 0.3 Hz 截止频率平滑后驱动关卡对象通断、地形几何显隐与 HUD 反馈,形成"环境光即输入"的独特玩法。实验结果表明,在 IMX6U 板上以 1024×600、30 FPS 稳定运行,光照变化到游戏世界响应延迟约 0.7 s,能够支撑完整的关卡切换与死亡重生流程,验证了纯 CPU 渲染 + 环境传感器输入的技术路线在教学级嵌入式设备上的可行性。
**关键词:** IMX6ULL;软光栅化;RGB565;AP3216C;I²C;定点数;平台跳跃游戏;嵌入式 Linux
**A 2D Platformer on IMX6U with a Photo-Sensor as Gameplay Input**
Student name: Huang Jun Advisor: ⟨Advisor Name⟩
**Abstract** This training addresses the problem of building a playable graphical game on an embedded SoC (IMX6ULL / Cortex-A7) that has no GPU or only extremely limited graphics acceleration. A pure-CPU software rasterisation 2D framework `Core` was designed and implemented, together with a platform-jumper game `LightGame` that uses an AP3216C ambient-light sensor as its primary gameplay input. The renderer standardises on an RGB565 framebuffer, RGBA5551 sprite atlases, 1-bit transparency and integer fixed-point brightness modulation, keeping pixel-format conversion at the display boundary and confining hot paths to integer math and pre-allocated buffers. The display layer is abstracted behind a `Platform::IDisplay` interface with Linux `/dev/fb0` and PC SDL2 back-ends, selected by a CMake option and conditional compilation. The game layer implements sub-pixel-accumulator physics, a finite-state character controller, an integer smoothing follow-camera, a room-grid partition and checkpoint respawn. The sensor layer bypasses the vendor `ap3216c` character driver, taking over the I²C address via `ioctl(I2C_SLAVE_FORCE)` on `/dev/i2c-0`, reconfigures the AP3216C to ALS-only continuous mode at maximum gain, and applies a Q8 fixed-point first-order EMA (≈0.3 Hz cut-off) on top. The smoothed 12-bit reading drives object solidity, tilemap hot-swap and HUD feedback, realising an "ambient light *is* the input" mechanic. On the IMX6U board the system runs at 1024×600, a steady 30 FPS, with an end-to-end light-to-world response of about 0.7 s, supporting a complete level-transition and death-respawn flow, and confirming that a pure-CPU renderer plus an ambient sensor is a viable technical route for educational embedded devices.
**Keywords:** IMX6ULL; Software rasterisation; RGB565; AP3216C; I²C; Fixed-point; Platformer; Embedded Linux
---
**目 录**
1 专业方向综合实训概述 1
 1.1 实训目的 1
 1.2 实训任务 1
 1.3 实训要求 1
 1.4 环境及可持续性发展 2
2 平台与总体设计 3
 2.1 IMX6U 平台约束 3
 2.2 分层架构与依赖方向 3
 2.3 关键技术选型 4
3 Core 底层渲染库设计 5
 3.1 平台抽象与显示后端 5
 3.2 FrameBuffer 与统一像素格式 6
 3.3 DrawContext 绘制入口 7
 3.4 Sprite 与 Tilemap 数据结构 7
 3.5 整数定点与热路径规则 8
 3.6 Timer 与固定帧率 9
4 LightGame 游戏系统实现 10
 4.1 主循环与初始化 10
 4.2 Physics2D:亚像素积分物理 10
 4.3 PlayerController:状态机与二段可变高跳跃 11
 4.4 Camera2D:整数平滑跟随 12
 4.5 Level / Room:数据驱动的关卡与房间网格 12
 4.6 LevelRenderer:分层合成顺序 13
 4.7 GameStateManager 与 HUD 13
5 光敏传感器与游戏机制耦合 14
 5.1 AP3216C 与出厂驱动的问题 14
 5.2 i2c 直读方案与 I2C_SLAVE_FORCE 14
 5.3 Q8 定点 EMA 滤波 15
 5.4 光照 → 对象通断(LightEffectSystem) 16
 5.5 光照 → 地形几何显隐 16
 5.6 光照 → HUD 视觉反馈与手动调试通道 17
6 跨平台构建与调试 18
 6.1 CMake 选项与条件编译 18
 6.2 LevelEditor:PC + Debug 独占的关卡编辑器 18
 6.3 调试流程与验证方法 19
7 功能测试与验证 20
 7.1 渲染性能与帧率测试 ⟨待补⟩
 7.2 传感器响应曲线测试 ⟨待补⟩
 7.3 关卡完整通关测试 ⟨待补⟩
8 工作总结与展望 21
参考文献 22
---
# 1. 专业方向综合实训概述
> 本节整体框架待课程正式发布"实训任务书"后按其标题与要点回填;当前保留与《MIPS 单周期处理器设计》一致的骨架。
## 1.1 实训目的
⟨待课程发布后按任务书表述回填;预期覆盖点:嵌入式 Linux 应用开发能力、外设驱动理解、软硬件协同的系统性思维、以中小型工程项目锻炼工程管理与文档能力。⟩
## 1.2 实训任务
在 IMX6ULL 平台上,独立完成一款可交互的图形化应用:包含图形渲染、外设输入、状态管理三块能力,并至少集成一路板载传感器作为输入源。
## 1.3 实训要求
⟨待课程发布后按任务书回填。当前预设要点:⟩
1. 使用 C/C++11,兼容嵌入式老工具链,代码经过 PC 与 ARM 交叉编译双路径验证;
2. 界面运行分辨率不低于 1024×600,图形帧率不低于 30 FPS;
3. 至少接入一路 I²C 或 SPI 外设并读取有效数据;
4. 项目需具备明确的分层结构与文档,能够独立部署到板端运行;
5. 提供实训报告与相关演示材料。
## 1.4 环境及可持续性发展
本次实训以 IMX6ULL 教学板 + PC 交叉开发环境为主,全过程使用软件仿真与实机验证结合。板端不需要额外硬件改造,桌面端使用 SDL2 模拟显示与输入,避免了反复烧写调试造成的电子元件损耗;同时项目采用离线资源转换(PNG → C 头文件、TTF → 位图字体 mask)方式,运行时不解码原始素材,减少 CPU 空转能耗,与嵌入式设备低功耗、长时运行的场景要求一致。项目分层清晰,`Core` 底层库可复用到后续其他嵌入式图形项目,符合可持续开发原则。
# 2. 平台与总体设计
## 2.1 IMX6U 平台约束
本项目的目标硬件为正点原子 IMX6ULL 教学开发板,其核心为 ARM Cortex-A7 单核处理器,未集成独立 GPU,显示输出通过直接向 `/dev/fb0` 写入帧缓冲完成,板载的 AP3216C 光/接近传感器则挂载在 I²C-0 总线上。与常见的移动 SoC 相比,该平台在图形计算、数值运算和输入通道三方面呈现出明显的资源约束,直接决定了后续渲染框架和游戏实现的技术路线。
在图形栈方面,IMX6ULL 不提供任何硬件加速,三角形填充、精灵位图、字体掩码等所有绘制操作都必须由 CPU 逐像素完成,因此渲染管线的热路径必须尽量降低计算开销。在数值运算方面,虽然 Cortex-A7 支持 VFPv4 浮点单元,但浮点指令的延迟和功耗均高于整数指令,在逐像素长循环中应避免使用浮点运算,以保证 30 FPS 的帧率预算。在输入通道方面,按键、触摸和传感器分别通过 evdev、tslib 和 i2c 字符设备或 sysfs 访问,内核并未提供统一的事件循环,应用层需要自行轮询各类设备状态。
上述约束共同决定了本项目的整体技术路线:核心渲染与游戏逻辑采用整数化计算,帧缓冲以单帧一次 memcpy 的方式提交,热路径避免堆分配,并将显示与输入细节完全抽象到平台层,从而使上层代码在 PC 与板端之间保持一致。
## 2.2 分层架构与依赖方向
为了保证代码在 PC 调试与 IMX6U 板端运行之间能够无缝迁移,项目采用严格的单向依赖分层,模块命名与目录结构一一对应(详见 `docs/APP_AND_CORE_ARCHITECTURE.md`),总体依赖方向为 `Apps -> Shared -> Core -> Platform`。其中,Platform 层位于最底层,负责屏蔽 SDL2、`/dev/fb0`、ALSA、evdev、I²C 等具体平台差异,向上以 `IDisplay`、`ITimeSource`、`IButtonInput`、`IKeyboardState`、`IPointerInput`、`IPhotoSensor` 等纯虚接口提供显示、时钟、按键、键盘、指针和光敏传感器能力;Core 层建立在 Platform 接口之上,包含 `FrameBuffer`、`DepthBuffer`、`DrawContext`、`Rasterizer`、`Tilemap`、`Sprite`、`BitmapFont`、`Timer` 等与具体游戏无关的运行时组件,为上层应用提供统一的 2D 渲染与定时服务;Apps 层位于最顶层,是各具体游戏的实现位置,本文的核心应用 `LightGame` 即位于 `src/Apps/LightGame/`,同项目中其他同学负责的 `Game`(Tom 游戏)与 `Demo` 也处于该层,但彼此独立、不存在直接依赖。需要说明的是,Shared 层按规划用于存放应用层共享的 UI、存档、配置等模块,当前目录结构中尚未填充具体实现,因此现有代码的实际依赖路径可视为 `Apps -> Core -> Platform`。
单向依赖通过 CMake target 与 include 路径共同约束:`Core -> Apps`、`Platform -> Apps` 以及 `GameA -> GameB` 这类反向引用都会在编译期直接暴露,从而避免平台代码或底层库被应用层细节污染。由于本文作者主要负责 `Core` 底层库与 `Apps/LightGame` 光敏平台跳跃游戏的实现,后续章节将围绕这两部分展开,仅在必要处引用另一位合作者负责的模块。
## 2.3 关键技术选型
在语言标准方面,项目选用 C++11,以兼容老旧的嵌入式交叉工具链(如 gcc-linaro-4.9.4),避免因引入 C++14/17 特性而导致板端编译失败。在帧缓冲格式方面,统一采用 RGB565,该格式与 IMX6ULL 板载 LCD 的物理显存布局一致,PC 端 SDL2 同样以 RGB565 streaming texture 接收,从而在全链路中省去像素格式转换。在精灵资源格式方面,采用 RGBA5551 的 1-bit 透明打包,足以表达像素风素材,同时把 5 位通道扩展到 RGB565 的转换成本限制在已知范围内。
在数值系统方面,核心路径统一使用整数运算,并在亮度调制和光敏滤波等场景引入 Q8/Q7 定点数,以屏蔽 VFP 浮点开销并消除浮点精度抖动。在显示后端方面,通过 `Platform::IDisplay` 抽象出 `FBDisplay` 与 `SDLDisplay` 两套实现,PC 与 ARM 共用同一份游戏和渲染代码。在输入抽象方面,按键、键盘、指针和光敏传感器分别对应 `IButtonInput`、`IKeyboardState`、`IPointerInput` 和 `IPhotoSensor` 接口,`DefaultHardware.h` 按平台做 typedef 切换,使得上层应用无需使用 `#ifdef`。在传感器读取方面,AP3216C 不依赖出厂字符设备驱动,而是直接通过 `/dev/i2c-0` 抢占地址并重配置寄存器,以获得稳定可用的 ALS 数据。
上述选型共同服务于一个目标:在嵌入式路径上保持整数化和固定像素格式,在 PC 路径上通过接口复用同一份实现,从而保证双平台下画面等价、行为一致,同时降低跨平台验证与后续维护的成本。
# 3. Core 底层渲染库设计
## 3.1 平台抽象与显示后端
`Platform::IDisplay` 是一个仅包含四个方法的纯虚接口:`init(width, height)`、`present(const Core::FrameBuffer*)`、`poll_events(bool& should_quit)` 与 `shutdown()`。它的核心思想是把“如何把像素缓冲送到屏幕”这一平台相关细节完全隔离到平台层,游戏逻辑只关心一块 RGB565 帧缓冲的内容。
### 3.1.1 FBDisplay(IMX6U 板端)
在 IMX6ULL 板端,`FBDisplay` 打开 `/dev/fb0`,通过 `ioctl` 读取 `FBIOGET_FSCREENINFO` 与 `FBIOGET_VSCREENINFO` 获取像素格式和行步长,随后用 `mmap` 将整块显存映射到用户空间指针 `fb_mem`。`present()` 根据 `vinfo` 中的 R/G/B 位掩码分三条路径提交:若面板本身就是 RGB565 且 `line_length == width * 2`,则一次性 `memcpy` 完成提交;若行步长不等,则改为逐行 `memcpy`。对于 ARGB8888 或 RGBA8888 面板,则对每个像素做 5→8 位扩展并重新排列字段。其余配置统一走 `convert_pixel()`,根据运行时读取的位掩码构造输出像素,保证对未知 fb 格式的兼容性。
板端没有 SDL 事件循环,`poll_events` 使用 `select(STDIN_FILENO, timeout=0)` 非阻塞检测 `q` 或 `Q` 键,使主循环不会被输入阻塞。`shutdown` 在 `munmap` 前先用 `memset` 清屏,避免程序退出后屏幕残留最后一帧画面。
### 3.1.2 SDLDisplay(PC 端)
PC 端 `SDLDisplay` 创建 `SDL_Window`、`SDL_Renderer(SDL_RENDERER_ACCELERATED)` 以及格式为 `SDL_PIXELFORMAT_RGB565`、访问模式为 `STREAMING` 的 `SDL_Texture`,与核心 `FrameBuffer` 的像素格式严格一一对应。`present()` 只需一次 `SDL_UpdateTexture`、`SDL_RenderCopy` 与 `SDL_RenderPresent`。这种设计让 PC 侧无需为像素格式转换编写第二条路径,确保 PC 与 ARM 的可见画面完全等价。
### 3.1.3 时间源与输入
`Platform::ITimeSource` 独立于显示层,提供单调递增的整数毫秒时钟:Windows 实现基于 `std::chrono::steady_clock`,Linux 实现基于 `clock_gettime(CLOCK_MONOTONIC)`,返回值均为 `uint32_t`,49 天回绕在一次游戏运行中可以忽略。按键、键盘与指针输入也采用同样模式——接口定义位于 `Core/Platform/`,ARM 端由 Evdev 系列实现,PC 端由 Sdl 系列实现,`DefaultHardware.h` 通过 typedef 决定调用点看到的具体类型。这种方式使得 `LightGameApp` 中不存在任何用于选择平台输入实现的 `#ifdef TARGET_IMX`。
## 3.2 FrameBuffer 与统一像素格式
`Core::FrameBuffer` 在构造时一次性分配大小为 `width * height` 的 `std::vector<uint16_t>` 作为 RGB565 像素缓冲,整个运行期间不再进行堆扩展。为了服务热路径绘制,它对外提供 `set_pixel_unsafe(x, y, rgba32)` 接口,调用方需自行保证坐标落在合法范围内,函数内部仅执行一次数组写入。颜色转换由头文件中的内联函数 `rgba_to_frame_pixel` 完成,其表达式仅包含 R 右移 3 位、G 右移 2 位、B 右移 3 位后的位或,编译器能够将这一短转换折叠进精灵和瓦片的内层循环。`get_buffer()` 返回缓冲区的原始指针,供 `FBDisplay` 或 `SDLDisplay` 直接 `memcpy` 或 `SDL_UpdateTexture` 使用,避免额外的数据拷贝。
与 `FrameBuffer` 配套的还有 `Core::DepthBuffer`,它同样使用 `uint16_t` 存储每个像素的深度值,清空时写入 `0xFFFF` 表示最远,语义为“值越小越近”。该缓冲主要服务于三角形光栅化路径,虽然 `LightGame` 作为纯 2D 游戏并不使用其内容,但 `DrawContext` 仍统一持有它,以保持渲染 API 的完整性和未来向 3D 扩展的可能性。
## 3.3 DrawContext 绘制入口
`Core::DrawContext` 是整个渲染管线的门面对象,构造时创建 `FrameBuffer`、`DepthBuffer`、`Rasterizer` 与 `TriangleRasterizer` 各一份,禁止拷贝和移动,所有动态分配集中在构造与析构阶段完成,运行期不再出现 new/delete。游戏层通过它访问的接口包括清屏、绘制线段与三角形、精灵与瓦片绘制、填充矩形与位图文本,以及最终的 `present(IDisplay*)` 帧提交。
在精灵绘制热路径上,源图集以 RGBA5551 格式存放,`blit_sprite_pixels` 在外层根据目标矩形完成一次屏幕裁剪,确定 `start_dx / dy` 与 `end_dx / dy` 区间;内层则是一个紧凑的 `for(sy) for(sx)` 双循环,行指针 `dst_row` 在外层预先算出,水平/垂直翻转通过对读索引的三元表达式实现,编译器可将其优化为条件传送,避免在内层引入分支。此外,`scale == 1` 与 `scale > 1` 两条路径分开实现,前者内层不含除法。1-bit 透明通过 `rgba5551_is_opaque` 的位测试跳过,路径上没有任何 alpha 混合或乘法,最短流程每像素仅一次读取、一次测试和一次写入。
瓦片绘制同样遵循整数化原则。`draw_tilemap_shaded` 以 `shade_numerator / (1 << shade_shift)` 的整数比例对每个颜色通道做定点缩放,具体实现细节见 §3.5。
## 3.4 Sprite 与 Tilemap 数据结构
`RenderData::Sprite` 被设计为一个轻量视图,仅保存指向源图集的 `const Image* atlas` 以及一个 `(x, y, w, h)` 子矩形,不持有像素数据本身。`Image` 则保存 `const void* pixels` 指针与宽高信息,像素默认按 RGBA5551 格式解释。所有精灵源图集在离线阶段由 `tools/png_to_header.py` 转换为 C 头文件中的 RGBA5551 `uint16_t` 数组,运行时不解码 PNG,也不进行格式转换,从而消除了运行时解析开销。
`RenderData::Tilemap` 用 `uint16_t tile_id` 表示地图网格,`0xFFFF` 作为空瓦片哨兵;当前实现支持单一 atlas 与固定 tile 尺寸,结构内同时保存地图宽高、tile 宽高和 atlas 列数。`draw_tilemap` 的裁剪分两层执行:首先在瓦片层根据相机位置计算 `start_tile_x = camera_x / tile_w`,并向左右、上下各多取一格,以覆盖像素级滚动时露出的一半瓦片;然后在像素层对每个瓦片按视口子矩形做进一步裁剪,只绘制实际可见部分。`DrawContext` 还提供了带 `viewport_w / viewport_h` 的重载,允许把地图渲染限制在屏幕上的任意子矩形内,`LightGame` 利用这一能力在屏幕顶部留出 24 px 的 HUD 黑边。
## 3.5 整数定点与热路径规则
为了在 IMX6ULL 上稳定达到 30 FPS,项目对核心渲染与游戏逻辑制定了严格的整数化约束:除导入导出、调试打印和上层表示等非热路径外,核心代码禁止使用浮点类型。这一约束贯穿于绘制、物理、光照滤波等各个环节。
在亮度调制场景中,`draw_tilemap_shaded` 接收 `(numerator, shift)` 参数,对每个颜色通道计算 `(component * numerator) >> shift`,其中绿通道还需做 5→6 位扩展 `(g << 1) | (g >> 4)`,以高位补齐低位的方式保持亮度分布均匀。整个乘法链仅包含整数乘法和右移,没有浮点参与。光敏值的平滑滤波采用 Q8 定点实现,HUD 光条颜色渐变同样使用纯整数运算,具体内容分别见 §5.3 和 §4.7。物理系统则以 `int32 px/s` 存储速度,并通过亚像素累加器完成位移积分,从而避免浮点累积误差。
## 3.6 Timer 与固定帧率
`Core::Timer` 负责控制主循环节奏,只接受 30、45、60 FPS 三档目标帧率,其它值会回退到 30。为了避免整数除法 `1000 / 30 = 33` 带来的截断误差累积,它采用余数累积法计算每帧的固定时间片:
```cpp
tick_remainder_ += 1000;
fixed_delta_ms_ = tick_remainder_ / fps;
tick_remainder_ %= fps;
```
在 30 FPS 下,上述代码会依次产生 33、33、34、33、33、34……毫秒的 tick,每 30 帧累加恰好为 1000 ms,与真实时间保持零漂移。`remaining_frame_ms(now_ms)` 则给出当前帧剩余的睡眠预算,主循环通过一次 `std::this_thread::sleep_for` 消化这部分时间,从而把帧率稳定在目标值附近。
# 4. LightGame 游戏系统实现
## 4.1 主循环与初始化
`src/Apps/LightGame/src/main.cpp` 中的主循环遵循“初始化 → 固定时间片更新 → 绘制 → 帧提交 → 睡眠等待”的朴素结构。初始化阶段首先根据 `TARGET_IMX` 或 `TARGET_PC` 宏创建对应的 `Platform::IDisplay` 实现,随后实例化 `DefaultButtonInput`、`DefaultKeyboardState`、`DefaultPointerInput` 与 `DefaultPhotoSensor`,这些具体类型由 `DefaultHardware.h` 按平台 typedef 决定。`Core::DrawContext` 和 `Core::Timer` 随后以 1024×600 分辨率和 30 FPS 目标创建,`LightGameApp` 拿到输入、显示和时间源的指针后进入主循环。
```cpp
Platform::IDisplay* display = CreateDisplay(); // 后端由 TARGET_IMX/PC 决定
display->init(ScreenWidth, ScreenHeight);
Platform::DefaultButtonInput buttonInput;
Platform::DefaultKeyboardState keyboardState;
Platform::DefaultPointerInput pointerInput;
Platform::DefaultPhotoSensor photoSensor;
// ... 初始化每个输入设备
Core::DrawContext ctx(ScreenWidth, ScreenHeight);
Core::Timer timer(30);
Platform::SteadyTimeSource time_source;
LightGame::LightGameApp app(...);
while (!should_quit) {
timer.begin_frame(time_source.get_time_ms());
display->poll_events(should_quit);
buttonInput.update();
pointerInput.update();
app.update(timer.fixed_delta_ms());
app.draw(ctx);
// 帧提交:IMX 走 ctx.present(display),PC 直接 SDL_UpdateTexture
SleepRemainingFrameTime(timer, time_source);
}
```
`app.draw` 完成所有游戏对象的绘制,`ctx.present(display)` 在板端通过 `FBDisplay` 一次性提交到 `/dev/fb0`。PC 端的主循环则直接使用 `SDL_UpdateTexture` 与 `SDL_RenderCopy` 上传帧缓冲,以便与 SDL 渲染器和调试用的 ImGui 叠加;这是为 PC 调试生态刻意保留的分层例外,板端代码路径仍然严格遵守 `IDisplay` 抽象。`SleepRemainingFrameTime` 在每帧末尾消耗由 `timer.remaining_frame_ms` 计算出的剩余时间,从而把实际帧率稳定在 30 FPS 附近。
## 4.2 Physics2D:亚像素积分物理
`Physics2D` 是 LightGame 的物理层,默认参数为重力 `800 px/s²`、最大下落速度 `600 px/s`、地面摩擦力 `900 px/s²`。所有速度均以 `int32 px/s` 为单位存储,位移积分通过亚像素累加器完成。`Physics2D` 内部维护两个 `int32_t` 余数 `sub_pixel_x_` 与 `sub_pixel_y_`,每帧先把 `velocity * dt_ms` 累加到余数上,再除以 1000 得到整像素位移并保留余数:
```cpp
sub_pixel_x_ += velocity.x * dt_ms; // 单位:px·ms/s
int32_t dx = sub_pixel_x_ / 1000;
sub_pixel_x_ -= dx * 1000; // 保留亚像素余数
```
以 50 px/s 的速度、33 ms 的帧时间为例,一帧的理论位移仅为 1.65 像素,若没有亚像素累加器,整除截断会导致物体静止或出现帧率相关的抖动;引入累加器后,这些微小位移会在多帧间累积并正确兑现。
碰撞解算采用最小分离轴策略。对于 Tilemap 碰撞,`Physics2D` 先根据 `world_collider` 算出覆盖的 tile 范围,再遍历每个 solid tile,分别计算左右上下四个方向的穿透深度,选择绝对值最小的方向将物体推出,同时清零该轴的速度和亚像素余数,防止冲量遗留。动态实体之间的碰撞同样使用四方向最小分离,但仅清零速度而保留亚像素余数,这样当多个移动平台轻微重叠时,余数可以在下一帧帮助抵消抖动。若对象掉出地图底端 128 像素以外的安全裕量,则会被标记为 `active = false`,避免进入无限下坠的死循环。
## 4.3 PlayerController:状态机与二段可变高跳跃
`PlayerController` 的运动参数默认值如表所示。
| 项 | 值 | 说明 |
| --- | --- | --- |
| move_speed | 200 px/s | 水平最大速度 |
| acceleration | 1200 px/s² | 起步加速度 |
| deceleration | 1600 px/s² | 松开方向后的减速 |
| jump_velocity | −420 px/s | 一次跳跃的初速 |
| jump_cut_multiplier | 40 % | 松开跳跃时上升速度按 40% 截断 |
角色状态机在 `Idle`、`Running`、`Jumping`、`Falling`、`Dead` 五个状态之间转移,每帧根据 `grounded`、`move_dir` 和 `velocity.y` 的组合确定下一状态,没有隐藏状态。输入处理同时支持键盘与触摸屏:键盘使用 `←/→` 控制水平移动、`↑` 控制跳跃;触摸屏则将屏幕上半区映射为跳跃,左三分之一和右三分之一分别映射为向左和向右移动。当两者同时存在时键盘优先覆盖触摸,因此同一份 `PlayerController` 既能在 PC 上调试,也能在 IMX6U 触摸屏上直接运行,无需平台分支。
可变高跳跃通过检测跳跃键的按下沿和释放沿实现。起跳时若按键按住,角色获得 `−420 px/s` 的初速度;在上升阶段(`velocity.y < 0`)一旦检测到按键释放,立即将竖直速度按 `jump_cut_multiplier / 100` 截断为原来的 40%。轻按即小跳、长按即高跳,这是平台跳跃类游戏的常见手感设计。
死亡判定分为两种情况。第一种是角色位置越过关卡底部边界,立即进入 `Dead` 状态。第二种是碰到尖刺 tile,此时只有碰撞体进入 tile 顶部向下 14 px 的区域内才会判定死亡,边缘擦过不会触发。这一 14 px 的杀伤区在源码中以注释明确说明,是刻意保留的手感缓冲。
## 4.4 Camera2D:整数平滑跟随
`Camera2D` 是一个完全整数化的死区跟随相机。其实际视口为 1024 × (600 − 24) 像素,顶部留出 24 px 给 HUD 黑边;水平与垂直死区分别为 60 与 40 像素,平滑系数 `smooth_shift_` 固定为 3。跟随算法的核心思想是:先计算目标点与视口中心的偏移,若偏移超出死区的一半,则将偏移右移 3 位作为本次移动步长;由于右移结果可能为 0,此时用 ±1 像素兜底,防止相机停留在 1~7 像素的残余距离上无法追齐。
```cpp
int32_t dx = target.x - (position.x + viewport.w / 2);
if (abs(dx) > dead_zone.w / 2) {
int32_t step = dx >> smooth_shift_; // 整数近似指数平滑
if (step == 0) step = (dx > 0 ? 1 : -1); // 避免残余不动
position.x += step;
}
// y 轴同理
clamp_to_bounds();
```
用右移代替除法(`dx >> 3` 等价于 `dx / 8`)不仅避免了浮点运算,在 ARM 上也是最廉价的分频方式。每帧更新后,相机位置还会被钳制到当前房间的边界内;当房间尺寸小于视口时,相机会直接吸附到房间的左上角,形成类似“回信箱”的取景效果,常用于竖直井道等狭小空间。
## 4.5 Level / Room:数据驱动的关卡与房间网格
LightGame 的关卡数据完全离线烘焙,运行时不需要任何文件 I/O。`LevelData` 是一个 POD 结构,包含前景/背景 tile 数组指针、`ObjectSpawn` 对象生成表、`TileLightRule` 光照规则表、出生点坐标与地图边界。所有这些内容都在 `src/Apps/LightGame/src/levels/*.h` 中以 C 数组形式给出,编译期即定案。
`Level` 类在内存中持有三份 tile 缓冲区视图:`original_tiles_` 保存只读的原始地图,`tile_buffer_` 作为可写的运行时前景,`background_tiles_` 作为可写背景。`LightEffectSystem::update_tilemap` 根据 `TileLightRule` 在这三份视图之间操作:当光照落在规则窗口内时,从 `original_tiles_` 恢复对应格子;否则写入 `EmptyTile`(0xFFFF)。由于每次操作只涉及规则数量而非整张地图,其复杂度为 O(rules)。
`RoomLayout` 则用整数网格把大关卡切分成若干矩形房间。`RoomGrid` 包含列数、行数、每个房间的像素宽高以及 `RoomDef` 数组,查询 `room_index_of(grid, world_pos)` 只需两次整除加上边界钳制即可得到当前房间索引。每个 `RoomDef` 携带 `RoomBounds` 与 `default_spawn`,切换房间时相机会立即更新边界,其它房间的检查点自动失效,从而形成类似 Metroidvania 的分区探索体验,同时避免了运行时加载关卡的额外开销。
## 4.6 LevelRenderer:分层合成顺序
`LevelRenderer` 每帧按照从远到近的顺序合成画面。首先绘制可选的背景 tilemap,调用 `draw_tilemap_shaded` 并指定 `shade_numerator=80`、`shade_shift=7`,即以 80/128 的整数比例将背景压暗到约 0.625 倍亮度,全部通过定点乘法完成,不引入浮点。接着绘制前景 tilemap,使用未经调暗的 `draw_tilemap` 快路径。然后遍历对象数组绘制非玩家对象,绘制前通过 `LightEffectSystem::should_render` 判断可见性:对于 `LightPlatform`、`ShadowPlatform` 和 `Door`,无论其当前 `solid` 状态如何都保持渲染,以便玩家读取光照规则;其它对象则以 `obj.solid` 作为是否绘制的依据。最后绘制玩家精灵,使其始终位于最上层。
在 `IMX6U_DEBUG` 调试构建中,`LevelRenderer` 还会追加一层 `draw_debug`,调用 `Rasterizer::DrawLine` 为每个活动对象绘制轴对齐碰撞盒,不同对象类型使用不同颜色。这也是当前项目中 `Rasterizer::DrawLine` 的主要使用场景。
## 4.7 GameStateManager 与 HUD
`GameStateManager` 管理游戏的高层状态机,包含 `Title`、`Playing`、`Paused`、`GameOver`、`LevelComplete` 五个状态。ESC 键通过 `esc_was_down_` 进行边沿检测,确保每次按键只触发一次状态切换;Title 状态下开始提示以 500 ms 为周期闪烁,引导玩家按键进入游戏。
HUD 由 `GameStateManager` 与 `LightGameApp` 协作绘制。屏幕左上角有一条宽 80 px 的光照指示条,其填充宽度为 `light_level * 78 / max_light_level`,填充颜色使用整数线性梯度:红色分量随光照增强而减小、绿色分量随光照增强而增大,黑暗时偏红、明亮时偏绿,玩家可以一眼判断当前光照区间。右上角显示当前关卡号 `LV{n}`,左下角显示金币数 `× n`,左侧还以小红块阵列表示剩余生命数。暂停时屏幕中央绘制一个半透明遮罩,当前使用 `Color(0, 0, 0, 200)`,但由于 RGB565 帧缓冲没有 alpha 通道,实际显示为纯黑遮罩,这是渲染管线带来的已知表现限制。
# 5. 光敏传感器与游戏机制耦合
**本节是本次实训中作者认为最有信息含量的部分:光敏传感器不是简单的"控件",而是一条完整的传感-滤波-语义映射链。**
## 5.1 AP3216C 与出厂驱动的问题
AP3216C 是一颗集环境光(ALS)、接近(PS)和红外(IR)于一体的三合一传感器芯片。IMX6ULL 教学板的出厂内核提供了 `ap3216c` 字符设备驱动,用户空间理论上可以通过 `open("/dev/ap3216c")` 再 `read()` 获取 ALS/PS/IR 三个字节的数据。但在实测中,这条路径并不可靠,主要表现为两个方面。
一方面,`read()` 经常返回 `EINVAL` 或读到全零。其根本原因在于出厂驱动将 `SysConfig(0x00)` 配置为 `0x03`,即 ALS、PS、IR 同时开启,而 ALS 采样与 PS 中断共用同一 ADC 通道;当 PS 触发中断采样时,会打断 ALS 的转换过程,用户空间如果在两次触发之间读取,就可能拿到未完成转换的旧值或全零。
另一方面,该内核驱动已经占用了 I²C-0 总线上 0x1E 地址,`i2cdetect -y -r 0` 会显示该地址为 `UU`(被内核持有)。此时普通的 `ioctl(I2C_SLAVE, 0x1E)` 会返回 `EBUSY`,用户态程序无法再通过标准 I²C 从设备接口与该地址通信。
因此,为了获得稳定、随环境变化的 ALS 数据,项目选择绕开出厂字符设备驱动,直接通过 `/dev/i2c-0` 与 AP3216C 芯片寄存器对话。这也是后续 i2c 直读方案的设计出发点。
## 5.2 i2c 直读方案与 I2C_SLAVE_FORCE
`Ap3216cPhotoSensor` 的实现思路是直接打开 I²C 总线节点 `/dev/i2c-0`,然后使用 `ioctl(fd_, I2C_SLAVE_FORCE, 0x1E)` 强制占用 0x1E 地址,从而跳过内核驱动的锁检查。初始化阶段向 AP3216C 写入两组配置:一是将 `SysConfig(0x00)` 设为 `0x01`,切换到 ALS-only 连续采样模式,避免 PS 中断打断 ALS;二是将 `AlsConfig(0x10)` 设为 `0x30`,使增益位 `gain[5:4]=11`,选择 323 lux 满量程的最大增益档位,以充分利用 12 位 ADC 的室内光分辨率。
```cpp
fd_ = open("/dev/i2c-0", O_RDWR);
ioctl(fd_, I2C_SLAVE_FORCE, 0x1E); // 强占地址,忽略 UU 状态
// SysConfig(0x00) = 0x01 → ALS-only 连续采样
write(fd_, {0x00, 0x01}, 2);
// AlsConfig(0x10) = 0x30 → gain[5:4]=11,full scale 323 lux,最大增益
write(fd_, {0x10, 0x30}, 2);
```
之后,每帧 `update()` 执行两次“写寄存器地址再读一字节”的事务,分别读取 `0x0C`(ALS 低字节)和 `0x0D`(ALS 高字节),组合成 `uint16_t als = (hi << 8) | lo` 并钳制到 4095。如果某次读取失败,驱动层保留上一次的值,防止单次 `ETIMEDOUT` 造成亮度归零跳变,其余平滑处理交由应用层完成。
这一方案有几个关键点。首先,`I2C_SLAVE_FORCE` 是必须的,因为普通 `I2C_SLAVE` 在地址已被内核驱动持有时会返回失败,只有 `_FORCE` 版本能跳过锁检查,实现用户态与内核驱动共享同一 I²C 地址。其次,ALS-only 模式让 ADC 只服务 ALS 通道,采样率稳定在数据手册标称的连续模式(约 100 ms/次),足以满足 30 Hz 主循环的需求。再次,最大增益 323 lux 的满量程比 20 kLux 低增益档位更适合室内环境,能把台灯、手电或手遮挡产生的光强变化映射到 12 位 ADC 的有效区间。最后,驱动层仅做“读失败保持上值”这一最小容错,不做额外平滑,避免在板端引入不必要的计算。
## 5.3 Q8 定点 EMA 滤波
原始 ALS 值在手指快速晃过或荧光灯高频闪烁时抖动明显,直接在 12 位裸值上驱动游戏会导致对象和地形频繁通断。为此,项目在 `LightGameApp` 中引入了一级 Q8 定点一阶指数移动平均(EMA)滤波,核心代码如下:
```cpp
// 状态:smoothed_light_q8_(int32,= real 值 × 256)
int32_t target_q8 = static_cast<int32_t>(raw) << 8; // 12-bit → Q8
smoothed_light_q8_ += (target_q8 - smoothed_light_q8_) >> 4; // α = 1/16
uint16_t light = static_cast<uint16_t>(clamp(smoothed_light_q8_ >> 8, 0, 4095));
```
该滤波器的平滑系数 α 等于 2⁻⁴,即 1/16。在 30 Hz 采样率下,其截止频率约为 `f_c ≈ f_s · α / (2π) ≈ 0.30 Hz`,从 10% 上升到 90% 的响应时间约为 0.7 秒。这一速度对“举起手电”或“用手遮挡”这类交互而言较为舒适:既能滤除手指抖动和 ADC 量化噪声,又不会让玩家感到明显延迟。
采用 Q8 定点表示而非在 12 位裸值上直接滤波,是为了保留足够的头部空间。若直接对原始值执行 `smoothed += (raw - smoothed) >> 4`,当误差绝对值小于 16 时右移结果会归零,滤波器将停在错误值上不再更新。将数值左移 8 位到 Q8 后,即使差分很小也能被逐步吸收,从而保证缓慢变化的光照(例如拉窗帘)不会失锁。
在 PC 调试通道中,W/S 键以 ±256 的步长直接调节 `manual_light_level_`,并将 `smoothed_light_q8_` 同步设置到对应值,因此手动模式与传感器模式之间切换时不需要额外过渡,光照变化保持连续。
## 5.4 光照 → 对象通断(LightEffectSystem)
`LightEffectSystem::update(objects, light)` 将滤波后的光照值映射到三类关卡对象的实体状态,具体规则如下表所示。
| 对象类型 | 逻辑 | 直觉解释 |
| --- | --- | --- |
| `LightPlatform` | `solid = light >= threshold.min_level` | 被点亮时才凝固的平台 |
| `ShadowPlatform` | `solid = light <= threshold.max_level` | 只在阴影里存在的平台 |
| `Door` | `solid = !(min ≤ light ≤ max)` | 在给定亮度窗口内才开门 |
对象的 `solid` 字段直接决定 `Physics2D` 是否将其纳入碰撞解算:当 `solid == false` 时,玩家可以像穿过空气一样穿过该对象。视觉上,`LightPlatform` 和 `ShadowPlatform` 分别拥有 on/off 两组精灵,`Door` 拥有 closed/open 两组精灵,`LevelRenderer` 每帧根据当前 `solid` 状态切换贴图,实现“随光照凝固或消失”的效果。
这一设计的优势在于复用了 `GameObject` 已有的 `solid` 字段,物理层不需要为光敏对象编写特殊分支,代码路径保持统一;同时,关卡编辑器只需要为对象填写 `min_level` 和 `max_level` 两个 12 位整数,即可表达“在多亮时能够踩上去”的规则,制作过程直观且不易出错。
## 5.5 光照 → 地形几何显隐
除了移动对象的实体状态,光照还可以改变地形几何本身。`TileLightRule` 由 `tile_x`、`tile_y`、`light_min` 和 `light_max` 四个字段组成,`LightEffectSystem::update_tilemap(level, light)` 逐条规则进行判断:若当前光照值落在规则窗口内,则将 `tile_buffer_[y*w + x]` 恢复为 `original_tiles_[y*w + x]` 中的真实 tile;否则写入 `Tilemap::EmptyTile`(0xFFFF),使该格从前景中消失。
这里采用“原始 + 可变”双缓冲而不是原地翻转,是因为同一格子可能在不同光照窗口下多次进出,必须以 `original_tiles_` 作为权威真相。这种设计让地图本身能够随光照消失或浮现,例如光下才显形的桥、暗中才崩塌的地板等效果,都可以通过规则配置实现。由于每次更新只涉及规则数量而非整张地图,其时间复杂度为 O(rules),与地图尺寸无关。
`Tilemap::EmptyTile` 同时被渲染层和物理层识别:渲染层遇到空瓦片跳过绘制,物理层的 `is_solid_tile` 对空瓦片返回 false。因此一次写入即同时完成“看不见”和“走得过去”两种语义,无需在其它系统中进行额外同步。
## 5.6 光照 → HUD 视觉反馈与手动调试通道
光照不仅影响游戏逻辑,也是玩家感知当前环境状态的第一视觉线索。HUD 左上角的光照指示条以颜色渐变实时展示当前 `light` 值,具体实现已在 §4.7 中说明。
在 PC 端开发时,由于没有真实的光敏传感器硬件,项目提供了手动调试通道:按下 `W` 或 `S` 键可以以 ±256 的步长调节 `manual_light_level_`,并置位 `has_manual_override_` 标志,使游戏绕过传感器读取而使用手动值。这样开发者可以在桌面环境中验证所有光敏关卡逻辑,这也是关卡编辑器在无硬件条件下的主要测试手段。
此外,`IPhotoSensor` 的 PC 实现 `SdlPhotoSensor` 默认返回 2048,即 12 位量程的中点。该默认值保证桌面构建启动时处于“中等亮度”,避免开局黑屏或过度曝光,使调试体验与板端真实光照环境保持接近。
# 6. 跨平台构建与调试
## 6.1 CMake 选项与条件编译
顶层 `CMakeLists.txt` 通过两组开关驱动条件编译。第一组是 `TARGET_IMX`,默认关闭;开启时定义 `TARGET_IMX` 宏,将 `FBDisplay`、`AlsaAudioInput`、`EvdevButtonInput` 和 `Ap3216cPhotoSensor` 等板端实现编入核心库;关闭时则定义 `TARGET_PC` 宏,编入 `SDLDisplay`、`SdlAudioInput`、`SdlKeyboardButtonInput`、`SdlPhotoSensor` 等 PC 实现,同时把 `third_party/imgui` 一并加入构建。第二组是 `$<CONFIG:Debug>` 生成器表达式,仅在 Debug 构建中定义 `IMX6U_DEBUG`,用于门控关卡编辑器、ImGui、F1 切换、调试 HUD 与碰撞盒可视化等调试功能。
`Apps/LightGame/CMakeLists.txt` 复用了同一套开关,并通过生成器表达式把 `LevelEditor.cpp` 限制在 `NOT TARGET_IMX AND CONFIG:Debug` 的条件下,确保编辑器只在 PC + Debug 构建中进入源码列表,Release 或 ARM 交叉编译构建中连相关符号都不会产生:
```cmake
set(LIGHTGAME_EDITOR_GUARD "$<AND:$<NOT:$<BOOL:${TARGET_IMX}>>,$<CONFIG:Debug>>")
target_sources(IMX6U-LightGame PRIVATE
"$<${LIGHTGAME_EDITOR_GUARD}:${CMAKE_CURRENT_SOURCE_DIR}/src/editor/LevelEditor.cpp>")
```
`DefaultHardware.h` 把平台差异收敛到类型定义处,因此 `LightGameApp` 中没有任何用于选择输入或传感器实现的 `#ifdef`。唯一的平台分支保留在 `main.cpp` 中,围绕显示帧提交与 ImGui 叠加这一调试需求展开,属于为 PC 调试生态刻意保留的分层例外。
## 6.2 LevelEditor:PC + Debug 独占的关卡编辑器
`LevelEditor` 是一个基于 ImGui 的关卡编辑器,直接在内存中编辑 `Level` 实例。它提供四类主要功能:工具切换,包括 `TileBrush`、`ObjectPlace`、`Select` 和 `Eraser`;图层切换,可在 `Foreground` 与 `Background` 之间选择;属性面板,用于修改选中对象的类型以及光照阈值 `min_level / max_level`;导出功能,通过 `export_to_header(name)` 将当前 tile 数组与对象列表按 `LevelData` 期望的 C 头文件结构序列化后写入磁盘。
由于 `LevelData` 是编译期常量,编辑器的“导出”实际上是“生成新头文件 → 重新编译”的循环。这一权衡的好处是运行时零 I/O、零解码;代价是快速迭代时需要重新编译,时间约为几十秒。
在主循环中,`F1` 键用于切换编辑器可见性。`ImGui_ImplSDL2_ProcessEvent` 与 `LightGameApp` 的输入并行处理,当编辑器处于活动状态时,ImGui 焦点会接管键盘和鼠标事件,从而避免点击面板时误操作角色。
## 6.3 调试流程与验证方法
项目推荐分四步完成开发与验证。第一步是 PC + Debug:在 VS Code 或 MSVC 中编辑代码后,执行 `cmake --build build-win --config Debug` 并直接 F5 调试;此阶段利用 W/S 手动光照通道遍历所有光敏关卡逻辑,并通过 F1 打开关卡编辑器修关。第二步是 PC + Release:使用 `--config Release` 构建,验证发布态下的帧率与关键路径行为是否稳定。第三步是 ARM 交叉编译:执行 `cmake -B build-arm-fb -DCMAKE_TOOLCHAIN_FILE=cmake/toolchain-arm-linux-gnueabihf.cmake -DTARGET_IMX=ON`,随后将二进制通过 `scp` 传到板上,`chmod +x` 后直接运行;`TARGET_IMX=ON` 会自动启用 framebuffer 后端,`/dev/fb0` 与 `/dev/i2c-0` 在设备节点权限已配置好的情况下无需 root。第四步是板端验证:主循环内已包含光照读取失败时保持上次值的容错,观察重点包括光条是否随环境变化、检查点重生是否正常、跳过尖刺的 14 px 手感是否合理,以及房间切换时相机吸附是否到位。
# 7. 功能测试与验证
⟨本节等课程要求出来后再回填详细数据。以下先给测试项模板与预期指标。⟩
## 7.1 渲染性能与帧率测试
- 目标平台:IMX6U 教学板,1024×600,30 FPS 目标;
- 度量方法:主循环中埋点 `time_source.get_time_ms()` 记录每帧的 `update / draw / present / sleep` 四段时长,采样若干秒的均值/最大值;
- 预期结果:全场景稳定 30 FPS,`sleep` 阶段应占多数(说明未跑满 CPU);无掉帧、无撕裂。
## 7.2 传感器响应曲线测试
- 度量方法:以已知光源分别做 (a) 覆盖—打开、(b) 缓慢遮挡两组动作,记录 `raw` 与 `smoothed` 曲线;
- 预期结果:`raw` 抖动明显,`smoothed` 上升到 90% 的时间约 0.7 s,无过冲、无稳态偏差。
## 7.3 关卡完整通关测试
- 步骤:从 Title 开始,走过所有房间,触发若干 `LightPlatform / ShadowPlatform / Door` 与 `TileLightRule`,中途死亡 ≥ 1 次以验证检查点重生,最终触达 `LevelComplete`;
- 预期结果:状态机无死锁,房间切换无穿墙,检查点在预期位置激活,HUD 计数正确。
# 8. 工作总结与展望
本次实训完成了两块相互支撑的工作。其一是 `Core` 底层渲染库,它将“在 IMX6ULL 这类无 GPU 平台上,用 RGB565 帧缓冲和整数运算实现可玩 2D 画面”的实践,固化为 `IDisplay`、`DrawContext`、`Tilemap`、`Sprite`、`BitmapFont`、`Timer` 等可移植接口。其二是 `LightGame` 光敏平台跳跃游戏,围绕板载 AP3216C 光敏传感器,实现了从 i2c 直读、Q8 定点 EMA 滤波,到光照驱动对象通断、地形几何显隐与 HUD 反馈的完整闭环,并支持检查点重生与房间切换。
从技术层面看,本次实训有三点关键收获。第一,在嵌入式实践中,当出厂驱动的默认配置无法满足应用需求时,直接通过 `I2C_SLAVE_FORCE` 抢占地址并重配置传感器寄存器,是获得稳定输入的最短路径。第二,无 GPU 平台的热路径必须保持整数化与定点化:RGB565 帧缓冲、RGBA5551 1-bit 透明、Q8 EMA、Q7 亮度缩放、亚像素累加物理等任何环节引入浮点,都会给 CPU 带来不必要的额外开销。第三,通过接口加 typedef 隔离平台差异,比在主逻辑中大量使用 `#ifdef` 更易于维护;`DefaultHardware.h` 使得 `LightGameApp` 基本保持平台无关,PC 端的 W/S 手动光照、F1 关卡编辑器等调试通道也因此能够与板端代码 cleanly 共存。
当前实现仍存在一些局限,也为后续工作指明了方向。HUD 的半透明渲染受限于 RGB565 缺少 alpha 通道,暂停遮罩目前只能退化为不透明黑色,未来可以在 `DrawContext` 中增加 dither 或整数混合的 `fill_rect_blend` 路径。关卡数据全部编译期烘焙意味着修改关卡需要重新编译,后续可考虑将 `LevelData` 打包为板上可读的紧凑二进制并在启动时 mmap,以换取运行时改关能力,代价是增加一次 I/O 与解析。音频链路 `IAudioInput / IAudioOutput` 虽已抽象,但 LightGame 尚未接入,后续可让光照状态驱动音效,进一步强化“环境即输入”的沉浸感。此外,Launcher、LightGame 与合作者的 Game 目前仍是独立可执行文件,`docs/APP_AND_CORE_ARCHITECTURE.md` 中已规划 `IApp` 接口与单进程多应用切换,尚待落地。
总体来看,本次实训验证了在 IMX6U 这类无 GPU 嵌入式教学板上构建完整图形化游戏的可行性。通过把渲染、输入、传感器等每一层细节重新掌握在应用代码中,项目不仅实现了稳定的 1024×600@30 FPS 画面,也加深了对软硬件协同设计的理解。
# 参考文献
[1] Freescale Semiconductor. *i.MX 6UltraLite Applications Processor Reference Manual*. Rev. 2, 2016.
[2] LiteON. *AP3216C Ambient Light and Proximity Sensor Datasheet*. Rev. 1.5, 2015.
[3] Linux Kernel Documentation. *i2c/dev-interface — I²C/SMBus Character Device Interface*. https://www.kernel.org/doc/Documentation/i2c/dev-interface (accessed 2026-07).
[4] Linux Kernel Documentation. *fb/api.txt — The Linux Frame Buffer Device API*. https://www.kernel.org/doc/Documentation/fb/api.txt (accessed 2026-07).
[5] SDL Community. *Simple DirectMedia Layer 2.0 — Documentation Wiki*. https://wiki.libsdl.org/SDL2/ (accessed 2026-07).
[6] ⟨可选:正点原子.《I.MX6U 嵌入式 Linux 驱动开发指南》. 2020. ——若指导老师推荐加入⟩
[7] ⟨可选:唐佐林. 现代 C++ 嵌入式实战. 电子工业出版社, 2019. ——补充 C++11 相关引用⟩
Binary file not shown.
+41
View File
@@ -0,0 +1,41 @@
# 实训日记 — IMX6U 光敏平台跳跃游戏
## 第 1 天(7 月 6 日,周一):项目启动与选题
今天就要开始这个嵌入式的专业方向综合实训了。选题环节我几乎没有犹豫,直接定了做一个跑在 IMX6ULL 板子上的 2D 游戏——一方面自己之前用 C++ 写过一个跑在 CPU 上的软光栅小 demo,也用 Unity 做过一些小项目,对图形和游戏这一套流程比较熟;另一方面 IMX6ULL 没有 GPU,OpenGL 那一套走不通,反倒正好把之前软光栅的思路搬过来。翻了翻板子附带的手册,注意到板载有一颗 AP3216C 光敏传感器,就想干脆让传感器直接参与游戏机制,做一个"光照驱动"的平台跳跃:亮着的时候平台出现、暗下来时通道打开。今天大部分时间在读任务书、翻板载外设列表和写选题说明,正式的代码一行没写,但方向定下来之后心里踏实了不少。
## 第 2 天(7 月 7 日,周二):交叉编译环境与双后端抽象
今天开始搭开发环境。先在 Ubuntu 上装了 `gcc-arm-linux-gnueabihf` 工具链,写了一份 `cmake/toolchain-arm-linux-gnueabihf.cmake`,让 CMake 能一键切到 ARM 交叉编译。考虑到板子上直接调试太慢,日常肯定要在 PC 上跑,所以早早就决定把显示后端做成可切换的:PC 端走 SDL2 开窗口,ARM 端直接写 `/dev/fb0`。这就是 `IDisplay` 这个纯虚接口的由来,通过 CMake 里的 `USE_FRAMEBUFFER` 选项在编译期二选一,核心渲染代码完全不知道自己底下是 SDL 还是 framebuffer。傍晚把两端的 hello world 都点亮了——PC 上开出一个纯色窗口,板子的 LCD 也刷出了预期的彩色横条,虽然还只是最基础的像素输出,但双端并行的开发节奏算是搭起来了。
## 第 3 天(7 月 8 日,周三):Core 底层库的移植与重写
之前那份软光栅是 3D 的,齐次坐标、透视除法、深度缓冲一应俱全,这次做 2D 游戏根本用不到。今天的主要工作是把 `FrameBuffer`、`DrawContext`、`Timer` 这几个通用模块从旧项目里拆出来重写,只保留 2D 需要的部分。一个关键决定是:板载 LCD 是 RGB565 的,我把 `FrameBuffer` 的像素类型从原来的 `uint32_t` 换成了 `uint16_t`,Sprite atlas 也统一用 RGBA5551(1-bit alpha),这样一帧 1024×600 只占约 1.2 MB,比 32bpp 直接省一半带宽,对没有 GPU 的板子来说很重要。`Timer` 采用余数累积法维持 30 FPS,避免 `int` 除法舍入导致的帧率长期漂移。热路径规则也在今天正式立了下来:核心逻辑禁止浮点、禁止堆分配、SDL 相关的代码只允许出现在 `Core/Platform/` 目录,其他地方一概不许 `#include SDL.h`。
## 第 4 天(7 月 9 日,周四):主循环与 Physics2D 亚像素积分
今天开始搭游戏本体。主循环结构比较常规:`poll_events → update(dt) → render → present`,`Timer` 负责把变化的真实时间切成固定步长交给 `update`。真正花时间的是 `Physics2D`——由于禁止浮点,速度、加速度全部用 Q8 定点存储,每帧把速度累加到 `sub_pixel_x_ / sub_pixel_y_` 亚像素累加器上,只有跨过整像素时对象位置才真正变化。这样即便每帧位移不足 1 像素,累积几帧也能得到平滑的移动效果,跳跃初速度、重力、最大下落速度都能用整数精细调参。中间踩了个小坑:我一开始把 `sub_pixel_x_ / sub_pixel_y_` 挂在 `GameObject` 上,写着写着发现所有物理体都在重复同一份亚像素状态,才反应过来这两个字段本来就应该属于 `Physics2D` 组件,物理逻辑才更内聚。返工不算大,但也提醒自己接下来在划分类职责时要更谨慎一点。
## 第 5 天(7 月 10 日,周五):PlayerController 与 Camera2D
一周的最后一天,重点是让角色真正"活"起来。`PlayerController` 用状态机组织:Idle、Running、Jumping、Falling、Dead 五个状态之间根据输入和物理状态互相跳转。跳跃做成了"二段可变高"——按住跳键时持续施加上升力,松开或者达到最高高度切换到 Falling,这样短按能小跳、长按能高跳,手感一下就出来了。尖刺伤害判定选择了"tile 顶部向下 14 px"这样一个矩形区域,而不是整个 tile,避免玩家轻轻蹭到侧面就死掉。相机跟随算法花的时间比想象中久:直接线性插值会有浮点,改成 `dx >> smooth_shift_` 做整数右移,但发现 `dx` 只剩 1 时右移变 0,相机就再也追不上角色了,只好加一个 ±1 的兜底位移。周五收工的时候,小人已经能在临时关卡里跑跳、被尖刺秒杀、然后从头再来,虽然只是最裸的白盒关卡,但看着还是很有成就感的。周末先歇两天。
## 第 6 天(7 月 13 日,周一):Level / Room 与关卡编辑器
回来第一天,先把关卡数据结构定下来。`Level` 分三层:foreground(碰撞层)、background(装饰层)、objects(平台、门、检查点等交互对象);房间划分用 `RoomGrid`,每个房间对应一个 `RoomBounds`,`Camera2D` 在房间边界内 clamp,切换房间时相机做一次瞬移,形成类似 Metroidvania 的分屏效果。但用代码硬写 tile 数组实在太痛苦了,改一个空气块要跑一次编译、烧一次板子,效率完全不能忍。于是今天下午专门做了一个 PC 端的 `LevelEditor`:用 ImGui 显示 tile palette,鼠标左键刷 tile、右键擦除,F1 切换编辑器窗口,保存直接写回源文件的关卡数据数组。这个编辑器只在 PC 且 Debug 构建下编译,靠 CMake 里的 `LIGHTGAME_EDITOR_GUARD` 宏隔离,ARM Release 构建里连一个字节的 ImGui 都不会带进去,保证板端二进制体积干净。
## 第 7 天(7 月 14 日,周二):AP3216C 攻坚
今天正式接光敏传感器,也是这个项目里最折腾的一天。板子出厂 dts 里 AP3216C 的驱动是加载好的,`/dev/ap3216c` 字符设备也在,我按最直觉的方式 `open + read`,结果一直返回 `EINVAL`。翻了半天板载 SDK 里的驱动源码也没定位到具体是哪一步出的问题,各种参数组合都试过,就是拿不到稳定的读数。走到快中午的时候干脆决定绕开驱动:既然板子上 i2c 总线是通的,那就直接 `open("/dev/i2c-0")`,用 `ioctl(fd, I2C_SLAVE_FORCE, 0x1E)` 抢占 AP3216C 的地址,然后手写寄存器——`SysConfig = 0x01` 打开 ALS-only 模式,`AlsConfig = 0x30` 选最大增益 323 lux 满量程,再从寄存器 `0x0C / 0x0D` 读两字节合成 16 位光照原始值。第一次稳定看到 lux 数值随着我用手挡光的动作实时变化时,一整天的憋闷才散了。收工时把这一坑详细记进了项目笔记,免得日后忘了为什么要用 `I2C_SLAVE_FORCE` 而不是普通的 `I2C_SLAVE`。
## 第 8 天(7 月 15 日,周三):光照与游戏机制的耦合
有了稳定的 lux 输入,今天把光敏和游戏机制真正串起来。原始 lux 抖动不小,先在 `LightGameApp` 里加了一层 Q8 定点 EMA 滤波,`smoothed += (target - smoothed) >> 4`,也就是 α = 1/16,截止频率约 0.3 Hz、响应时间约 0.7 s,既能压掉手抖引起的高频噪声,也不至于慢到玩家察觉不到光变化。上层的 `LightEffectSystem` 定义了三类对象规则:`LightPlatform` 光照高于阈值时 `solid`、`ShadowPlatform` 光照低于阈值时 `solid`、`Door` 在特定区间内开启,一份规则表覆盖了几乎所有关卡玩法。地形几何显隐用 `TileLightRule` 加双缓冲实现——`original_tiles_` 永远保留关卡原始数据,`tile_buffer_` 是当前光照下重刷过的副本,光照跨越阈值时才重新生成一次 `tile_buffer_`,避免每帧都全图刷新。最后在 HUD 上加了一条光照条实时显示 smoothed lux,PC 端还挂了 W/S 手动调光通道方便调试。傍晚试玩的时候,看着小人跟着我用手挡光跳上跳下、又跟着灯光变化推开一扇门,那种"环境即输入"的味道终于对上了。
## 第 9 天(7 月 16 日,周四):板端联调与性能打磨
离答辩还剩一天,今天必须把所有功能在板子上跑一遍。第一个冒出来的问题是帧率:PC 端 30 FPS 稳如老狗,板子上从一个大房间进另一个大房间时会掉到 20+ FPS。在 `DrawContext::present` 前后加了 `gettimeofday` 打时间戳定位瓶颈,发现 `draw_tilemap_shaded` 对每个可见 tile 都做了亮度整数缩放(Q7 乘法 + 位移),而当时相机可视区域外的 tile 也在参与计算。改成先按 `Camera2D` 视口裁剪、只把落在屏幕内的 tile 送进 shading 循环之后,帧率立即稳回 30 FPS,甚至还有余量。第二个是 HUD 半透明:RGB565 没有 alpha 通道,暂停画面的半透明遮罩暂时只能退化成不透明黑色,试了 dither 但边缘噪点比较明显,最终决定先按不透明处理,报告的"局限与展望"里如实记下来。晚上把主线路径又跑了两遍:起点出发、经过检查点、光照驱动开门、切换房间、被尖刺送回检查点,一切符合预期。收工时看了眼时间,离明天答辩还有大概 18 个小时。
## 第 10 天(7 月 17 日,周五):报告收尾与答辩
今天就是答辩日了。上午把 `report_v2.md` 从大纲填成完整报告,中间反复对着源码核对每一处技术细节:Q8 EMA 的 α 值、AP3216C 的寄存器地址、亚像素累加公式、CMake 里的编译选项、`I2C_SLAVE_FORCE` 的宏值,尽量不让报告里出现一个和代码对不上的数字。下午答辩前又把 demo 视频重新录了一遍,特意在暗光和亮光两种环境下各跑一遍完整通关流程,把"环境即输入"这条主线讲清楚。回望这 10 天,从最开始定下"用 IMX6ULL + 软光栅 + 光敏传感器做游戏",到双后端抽象、Core 库移植、Physics2D 亚像素积分、状态机角色控制、关卡编辑器,再到 AP3216C 那一天的独自踩坑和光照耦合出效果的傍晚,每一步都能对应到具体的代码和当时的想法。写文档这类事情我一直不太喜欢,但真的一行行把这 10 天梳理出来的时候,那种"东西是自己一点点搭起来的"的踏实感确实不太一样。答辩就在下午,剩下的交给现场发挥吧。
+20
View File
@@ -0,0 +1,20 @@
[Window][Debug##Default]
Pos=60,60
Size=400,400
[Window][Tools]
Pos=0,20
Size=140,580
[Window][Status]
Pos=140,576
Size=884,32
[Window][Export Level]
Pos=312,200
Size=400,200
[Window][Properties]
Pos=804,20
Size=220,300
+6
View File
@@ -0,0 +1,6 @@
{
"name": "IMX6U-Game",
"lockfileVersion": 3,
"requires": true,
"packages": {}
}
+18 -10
View File
@@ -15,9 +15,10 @@
#include "font_atlas.h"
#include "test_sprite.h"
#ifdef USE_FRAMEBUFFER
#ifdef TARGET_IMX
#include "FBDisplay.h"
#else
#endif
#ifdef TARGET_PC
#include "SDLDisplay.h"
#endif
@@ -47,9 +48,10 @@ namespace
static Platform::IDisplay* CreateDisplay()
{
#ifdef USE_FRAMEBUFFER
#ifdef TARGET_IMX
return new Platform::FBDisplay();
#else
#endif
#ifdef TARGET_PC
return new Platform::SDLDisplay();
#endif
}
@@ -133,14 +135,20 @@ int main(int argc, char* argv[])
Platform::SteadyTimeSource time_source;
RenderData::BitmapFont font;
font.atlas = RenderData::Image(font_atlas_pixels, font_atlas_width, font_atlas_height);
font.mask_bits = font_atlas_mask;
font.atlas_width = font_atlas_width;
font.atlas_height = font_atlas_height;
font.char_w = font_char_w;
font.char_h = font_char_h;
font.columns = font_columns;
font.first_char = font_first_char;
RenderData::Image sprite_img(test_sprite_pixels, test_sprite_width, test_sprite_height, 0x00000000);
RenderData::SpriteRegion sprite_region(&sprite_img, 0, 0, sprite_img.width, sprite_img.height);
RenderData::Image sprite_img(
test_sprite_pixels,
test_sprite_width,
test_sprite_height,
RenderData::PixelFormat::RGBA5551);
RenderData::Sprite sprite(&sprite_img, 0, 0, sprite_img.width, sprite_img.height);
const std::array<uint16_t, 8 * 4> tileIds = {
0, RenderData::Tilemap::EmptyTile, 0, RenderData::Tilemap::EmptyTile, 0, RenderData::Tilemap::EmptyTile, 0, RenderData::Tilemap::EmptyTile,
@@ -173,9 +181,9 @@ int main(int argc, char* argv[])
ctx.clear_color(clearColor);
// sprite 测试
ctx.draw_sprite(10, 10, sprite_img);
ctx.draw_sprite_region_ex(30, 10, sprite_region, 2, false, false);
ctx.draw_sprite_region_ex(10, 30, sprite_region, 3, true, false);
ctx.draw_sprite(10, 10, sprite);
ctx.draw_sprite_ex(30, 10, sprite, 2, false, false);
ctx.draw_sprite_ex(10, 30, sprite, 3, true, false);
ctx.draw_tilemap(testTilemap, 650, 500, 96, 48, static_cast<int32_t>(frame_start_ms / 20u) % 32, 0);
// FPS 计数
+101
View File
@@ -0,0 +1,101 @@
set(DESKTOP_TARGET IMX6U-Desktop)
set(DESKTOP_ATLAS_HEADER "${CMAKE_CURRENT_SOURCE_DIR}/generated/desktop_atlas.h")
set_source_files_properties(${DESKTOP_ATLAS_HEADER} PROPERTIES GENERATED TRUE)
add_executable(${DESKTOP_TARGET}
Main.cpp
src/app/DesktopApp.cpp
src/ui/GameCatalog.cpp
src/ui/GameCarousel.cpp
src/ui/GameLauncher.cpp
${DESKTOP_ATLAS_HEADER}
)
set_source_files_properties(${CMAKE_CURRENT_SOURCE_DIR}/src/ui/GameCatalog.cpp
PROPERTIES OBJECT_DEPENDS "${DESKTOP_ATLAS_HEADER}"
)
target_include_directories(${DESKTOP_TARGET} PRIVATE
${CMAKE_CURRENT_SOURCE_DIR}/src
${CMAKE_CURRENT_SOURCE_DIR}/src/app
${CMAKE_CURRENT_SOURCE_DIR}/src/ui
${CMAKE_CURRENT_SOURCE_DIR}/generated
)
imx6u_configure_app_target(${DESKTOP_TARGET})
if(CMAKE_CROSSCOMPILING AND NOT EXISTS "${DESKTOP_ATLAS_HEADER}")
message(FATAL_ERROR
"Desktop atlas header is missing. Run GenerateDesktopAtlasHeader in a host build before cross compiling."
)
endif()
if(NOT TARGET_IMX)
set(DESKTOP_ATLAS_TOOL_SOURCES
tools/asset_pipeline/DesktopAssetTool.cpp
)
if(WIN32)
add_executable(DesktopAtlasTool EXCLUDE_FROM_ALL ${DESKTOP_ATLAS_TOOL_SOURCES})
target_include_directories(DesktopAtlasTool PRIVATE
${PROJECT_SOURCE_DIR}/libs/Win/SDL2/include
${PROJECT_SOURCE_DIR}/libs/Win/SDL_image/include
)
target_link_directories(DesktopAtlasTool PRIVATE
${SDL2_LIB_DIR}
${SDL2_IMAGE_LIB_DIR}
)
target_link_libraries(DesktopAtlasTool PRIVATE SDL2main SDL2 SDL2_image)
add_custom_command(TARGET DesktopAtlasTool POST_BUILD
COMMAND ${CMAKE_COMMAND} -E copy_if_different
"${SDL2_DLL}"
"$<TARGET_FILE_DIR:DesktopAtlasTool>"
)
add_custom_command(TARGET DesktopAtlasTool POST_BUILD
COMMAND ${CMAKE_COMMAND} -E copy_if_different
"${SDL2_IMAGE_DLL}"
"$<TARGET_FILE_DIR:DesktopAtlasTool>"
)
elseif(SDL2_image_FOUND)
add_executable(DesktopAtlasTool EXCLUDE_FROM_ALL ${DESKTOP_ATLAS_TOOL_SOURCES})
target_include_directories(DesktopAtlasTool PRIVATE
${SDL2_image_INCLUDE_DIRS}
${SDL2_IMAGE_INCLUDE_DIRS}
)
target_link_libraries(DesktopAtlasTool PRIVATE SDL2::SDL2)
if(TARGET SDL2_image::SDL2_image)
target_link_libraries(DesktopAtlasTool PRIVATE SDL2_image::SDL2_image)
else()
target_link_libraries(DesktopAtlasTool PRIVATE
${SDL2_image_LIBRARIES}
${SDL2_IMAGE_LIBRARIES}
)
endif()
else()
message(STATUS "DesktopAtlasTool disabled: SDL2_image was not found")
endif()
if(TARGET DesktopAtlasTool)
if(NOT CMAKE_CROSSCOMPILING)
add_custom_target(GenerateDesktopAtlasHeader
COMMAND ${CMAKE_COMMAND} -E make_directory
"src/Apps/Desktop/generated"
COMMAND $<TARGET_FILE:DesktopAtlasTool>
WORKING_DIRECTORY "${PROJECT_SOURCE_DIR}"
DEPENDS DesktopAtlasTool
COMMENT "Generating desktop atlas header from PNG assets"
VERBATIM
)
add_dependencies(${DESKTOP_TARGET} GenerateDesktopAtlasHeader)
else()
add_custom_target(GenerateDesktopAtlasHeader
COMMAND ${CMAKE_COMMAND} -E echo
"GenerateDesktopAtlasHeader is host-only. Run it in build-win or build-linux before cross compiling."
VERBATIM
)
endif()
endif()
endif()
+150
View File
@@ -0,0 +1,150 @@
#include <chrono>
#include <cstdint>
#include <iostream>
#include <thread>
#include "DefaultHardware.h"
#include "DrawContext.h"
#include "IDisplay.h"
#include "TimeSource.h"
#include "Timer.h"
#include "app/DesktopApp.h"
#include "ui/GameLauncher.h"
#ifdef TARGET_IMX
#include "FBDisplay.h"
#endif
#ifdef TARGET_PC
#include "SDLDisplay.h"
#endif
namespace
{
const int32_t ScreenWidth = 1024;
const int32_t ScreenHeight = 600;
const int32_t RestoreRetryCount = 5;
const uint32_t RestoreRetryDelayMs = 300;
static Platform::IDisplay* CreateDisplay()
{
#ifdef TARGET_IMX
return new Platform::FBDisplay();
#endif
#ifdef TARGET_PC
return new Platform::SDLDisplay();
#endif
}
static void SleepRemainingFrameTime(const Core::Timer& timer, const Platform::ITimeSource& timeSource)
{
const uint32_t sleepMs = timer.remaining_frame_ms(timeSource.get_time_ms());
if (sleepMs > 0u)
{
std::this_thread::sleep_for(std::chrono::milliseconds(sleepMs));
}
}
static bool RestoreDesktopHardware(
Platform::IDisplay* display,
Platform::DefaultPointerInput& pointerInput)
{
for (int32_t attempt = 1; attempt <= RestoreRetryCount; ++attempt)
{
if (display->init(ScreenWidth, ScreenHeight))
{
if (!pointerInput.init("", ScreenWidth, ScreenHeight))
{
std::cerr << "[WARN] Pointer input restore failed; desktop touch is disabled." << std::endl;
}
return true;
}
std::cerr << "[WARN] Failed to restore desktop display, retry "
<< attempt << "/" << RestoreRetryCount << "." << std::endl;
std::this_thread::sleep_for(std::chrono::milliseconds(RestoreRetryDelayMs));
}
std::cerr << "[ERROR] Failed to restore desktop display after retries." << std::endl;
return false;
}
static bool LaunchGameAndRestoreDesktop(
const Desktop::GameEntry& game,
Desktop::GameLauncher& launcher,
Platform::IDisplay* display,
Platform::DefaultPointerInput& pointerInput)
{
pointerInput.shutdown();
display->shutdown();
const bool launched = launcher.launch_and_wait(game);
if (!launched)
{
std::cerr << "[WARN] Game launch failed: " << (game.title ? game.title : "(unnamed)") << std::endl;
}
return RestoreDesktopHardware(display, pointerInput);
}
}
int main(int argc, char* argv[])
{
(void)argc;
(void)argv;
Platform::IDisplay* display = CreateDisplay();
if (!display->init(ScreenWidth, ScreenHeight))
{
delete display;
return -1;
}
Platform::DefaultPointerInput pointerInput;
if (!pointerInput.init("", ScreenWidth, ScreenHeight))
{
std::cerr << "[WARN] Pointer input init failed; desktop touch is disabled." << std::endl;
}
Core::DrawContext ctx(ScreenWidth, ScreenHeight);
Core::Timer timer(30);
Platform::SteadyTimeSource timeSource;
Desktop::DesktopApp app(ScreenWidth, ScreenHeight, &pointerInput);
Desktop::GameLauncher launcher;
std::cout << "[INFO] Desktop started. Target FPS: " << timer.target_fps() << std::endl;
bool isRunning = true;
while (isRunning)
{
timer.begin_frame(timeSource.get_time_ms());
bool shouldQuit = false;
display->poll_events(shouldQuit);
if (shouldQuit)
{
isRunning = false;
}
app.update(timer.fixed_delta_ms());
const Desktop::GameEntry* launchGame = app.consume_pending_launch();
if (launchGame != nullptr)
{
if (!LaunchGameAndRestoreDesktop(*launchGame, launcher, display, pointerInput))
{
isRunning = false;
break;
}
timer.begin_frame(timeSource.get_time_ms());
}
app.draw(ctx);
ctx.present(display);
SleepRemainingFrameTime(timer, timeSource);
}
pointerInput.shutdown();
display->shutdown();
delete display;
return 0;
}
+15
View File
@@ -0,0 +1,15 @@
IMX6U Desktop
=============
Game-console desktop application for the 1024x600 screen.
Interaction:
- Tap the left or right screen edge to switch games.
- Swipe horizontally to switch games.
- Tap the centered cover card to launch the selected game.
Adding a game:
1. Add a PNG cover to `assets/raw`.
2. Add it to `DesktopAssetTool.cpp`.
3. Add a `GameEntry` in `src/ui/GameCatalog.cpp`.
4. Rebuild the Desktop target to regenerate `generated/desktop_atlas.h`.
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 MiB

File diff suppressed because it is too large Load Diff
+58
View File
@@ -0,0 +1,58 @@
#include "DesktopApp.h"
#include <iostream>
#include "DrawContext.h"
#include "PointerInput.h"
#include "font_atlas.h"
namespace Desktop
{
DesktopApp::DesktopApp(int32_t width, int32_t height, Platform::IPointerInput* input)
: pointerInput(input),
screenWidth(width),
screenHeight(height),
pendingLaunchGame(nullptr)
{
font.mask_bits = font_atlas_mask;
font.atlas_width = font_atlas_width;
font.atlas_height = font_atlas_height;
font.char_w = font_char_w;
font.char_h = font_char_h;
font.columns = font_columns;
font.first_char = font_first_char;
carousel.configure(GetGameCatalog(), GetGameCatalogCount(), screenWidth, screenHeight);
}
void DesktopApp::update(uint32_t deltaMs)
{
if (pointerInput != nullptr)
{
pointerInput->update();
}
const CarouselAction action = carousel.update(deltaMs, pointerInput);
if (action == CarouselAction::Launch)
{
const GameEntry* game = carousel.get_current_game();
if (game != nullptr)
{
std::cout << "[INFO] Launching " << game->title << std::endl;
pendingLaunchGame = game;
}
}
}
void DesktopApp::draw(Core::DrawContext& ctx)
{
carousel.draw(ctx, font);
}
const GameEntry* DesktopApp::consume_pending_launch()
{
const GameEntry* game = pendingLaunchGame;
pendingLaunchGame = nullptr;
return game;
}
}
+38
View File
@@ -0,0 +1,38 @@
#pragma once
#include <cstdint>
#include "BitmapFont.h"
#include "GameCatalog.h"
#include "GameCarousel.h"
namespace Core
{
class DrawContext;
}
namespace Platform
{
class IPointerInput;
}
namespace Desktop
{
class DesktopApp
{
private:
Platform::IPointerInput* pointerInput;
GameCarousel carousel;
RenderData::BitmapFont font;
int32_t screenWidth;
int32_t screenHeight;
const GameEntry* pendingLaunchGame;
public:
DesktopApp(int32_t width, int32_t height, Platform::IPointerInput* pointerInput);
void update(uint32_t deltaMs);
void draw(Core::DrawContext& ctx);
const GameEntry* consume_pending_launch();
};
}
+333
View File
@@ -0,0 +1,333 @@
#include "GameCarousel.h"
#include <algorithm>
#include "Color.h"
#include "DrawContext.h"
#include "PointerInput.h"
namespace Desktop
{
namespace
{
const int32_t EdgeHotZone = 120;
const int32_t SwipeThreshold = 120;
const int32_t TapSlop = 18;
const uint32_t SlideDurationMs = 220;
static int32_t AbsInt(int32_t value)
{
return value < 0 ? -value : value;
}
static int32_t LerpInt(int32_t from, int32_t to, uint32_t elapsed, uint32_t duration)
{
if (duration == 0 || elapsed >= duration)
{
return to;
}
const int32_t delta = to - from;
return from + static_cast<int32_t>((static_cast<int64_t>(delta) * static_cast<int64_t>(elapsed)) / static_cast<int64_t>(duration));
}
}
GameCarousel::GameCarousel()
: games(nullptr),
gameCount(0),
currentIndex(0),
targetIndex(0),
screenWidth(0),
screenHeight(0),
dragStartX(0),
dragCurrentX(0),
dragStartY(0),
dragging(false),
pointerWasDown(false),
animationFromOffset(0),
animationToOffset(0),
animationElapsedMs(0),
animationDurationMs(SlideDurationMs)
{
}
void GameCarousel::configure(const GameEntry* catalog, size_t catalogCount, int32_t width, int32_t height)
{
games = catalog;
gameCount = catalogCount;
screenWidth = width;
screenHeight = height;
currentIndex = 0;
targetIndex = 0;
}
size_t GameCarousel::previous_index(size_t index) const
{
if (gameCount == 0)
{
return 0;
}
return index == 0 ? gameCount - 1 : index - 1;
}
size_t GameCarousel::next_index(size_t index) const
{
return gameCount == 0 ? 0 : (index + 1) % gameCount;
}
int32_t GameCarousel::get_card_width() const
{
return screenWidth - 144;
}
int32_t GameCarousel::get_card_height() const
{
return screenHeight - 156;
}
int32_t GameCarousel::get_card_x() const
{
return (screenWidth - get_card_width()) / 2;
}
int32_t GameCarousel::get_card_y() const
{
return 70;
}
bool GameCarousel::is_animating() const
{
return animationElapsedMs < animationDurationMs && currentIndex != targetIndex;
}
int32_t GameCarousel::get_current_offset() const
{
if (dragging)
{
return dragCurrentX - dragStartX;
}
if (is_animating())
{
return LerpInt(animationFromOffset, animationToOffset, animationElapsedMs, animationDurationMs);
}
return 0;
}
bool GameCarousel::is_inside_card(int32_t x, int32_t y) const
{
const int32_t cardX = get_card_x();
const int32_t cardY = get_card_y();
return x >= cardX && x < cardX + get_card_width() && y >= cardY && y < cardY + get_card_height();
}
void GameCarousel::begin_slide(size_t index, int32_t direction)
{
if (gameCount <= 1 || index == currentIndex)
{
return;
}
targetIndex = index;
animationFromOffset = direction * screenWidth;
animationToOffset = 0;
animationElapsedMs = 0;
animationDurationMs = SlideDurationMs;
}
CarouselAction GameCarousel::update(uint32_t deltaMs, Platform::IPointerInput* pointerInput)
{
if (is_animating())
{
animationElapsedMs += deltaMs;
if (animationElapsedMs >= animationDurationMs)
{
currentIndex = targetIndex;
animationElapsedMs = animationDurationMs;
}
}
if (gameCount == 0 || pointerInput == nullptr)
{
return CarouselAction::None;
}
const bool down = pointerInput->is_down();
const int32_t x = pointerInput->get_x();
const int32_t y = pointerInput->get_y();
if (down && !pointerWasDown && !is_animating())
{
dragging = true;
dragStartX = x;
dragCurrentX = x;
dragStartY = y;
}
else if (down && dragging)
{
dragCurrentX = x;
}
else if (!down && pointerWasDown && dragging)
{
const int32_t dx = x - dragStartX;
const int32_t dy = y - dragStartY;
dragging = false;
if (is_animating())
{
pointerWasDown = down;
return CarouselAction::None;
}
if (AbsInt(dx) > SwipeThreshold && AbsInt(dx) > AbsInt(dy))
{
begin_slide(dx < 0 ? next_index(currentIndex) : previous_index(currentIndex), dx < 0 ? 1 : -1);
}
else if (AbsInt(dx) <= TapSlop && AbsInt(dy) <= TapSlop)
{
if (dragStartX < EdgeHotZone)
{
begin_slide(previous_index(currentIndex), -1);
}
else if (dragStartX > screenWidth - EdgeHotZone)
{
begin_slide(next_index(currentIndex), 1);
}
else if (is_inside_card(dragStartX, dragStartY))
{
pointerWasDown = down;
return CarouselAction::Launch;
}
}
}
pointerWasDown = down;
return CarouselAction::None;
}
void GameCarousel::draw_card(Core::DrawContext& ctx, const GameEntry& game, int32_t x, int32_t y, bool active) const
{
const int32_t cardW = get_card_width();
const int32_t cardH = get_card_height();
const RenderData::Color shadow(0, 0, 0, 255);
const RenderData::Color border = active ? RenderData::Color(250, 230, 145, 255) : RenderData::Color(74, 82, 105, 255);
const RenderData::Color panel(20, 24, 36, 255);
ctx.fill_rect(x + 12, y + 14, cardW, cardH, shadow);
ctx.fill_rect(x - 4, y - 4, cardW + 8, cardH + 8, border);
ctx.fill_rect(x, y, cardW, cardH, panel);
if (game.cover != nullptr && game.cover->atlas != nullptr)
{
const int32_t coverX = x + (cardW - game.cover->width) / 2;
const int32_t coverY = y + (cardH - game.cover->height) / 2;
ctx.draw_sprite(coverX, coverY, *game.cover);
}
else
{
ctx.fill_rect(x + 28, y + 28, cardW - 56, cardH - 56, RenderData::Color(48, 56, 82, 255));
}
}
void GameCarousel::draw_text_centered(Core::DrawContext& ctx, const RenderData::BitmapFont& font, int32_t y, const char* text) const
{
if (text == nullptr)
{
return;
}
int32_t len = 0;
for (const char* p = text; *p; ++p)
{
++len;
}
const int32_t x = (screenWidth - len * font.char_w) / 2;
ctx.draw_text(font, x, y, RenderData::Color(245, 248, 255, 255), text);
}
void GameCarousel::draw_edge_hints(Core::DrawContext& ctx) const
{
ctx.fill_rect(0, 0, 42, screenHeight, RenderData::Color(9, 12, 20, 255));
ctx.fill_rect(screenWidth - 42, 0, 42, screenHeight, RenderData::Color(9, 12, 20, 255));
const int32_t cy = screenHeight / 2;
for (int32_t i = 0; i < 24; ++i)
{
ctx.fill_rect(24 - i / 2, cy - 24 + i, 3, 3, RenderData::Color(154, 168, 210, 255));
ctx.fill_rect(screenWidth - 27 + i / 2, cy - 24 + i, 3, 3, RenderData::Color(154, 168, 210, 255));
}
}
void GameCarousel::draw_page_dots(Core::DrawContext& ctx) const
{
if (gameCount == 0)
{
return;
}
const int32_t dotSize = 10;
const int32_t gap = 18;
const int32_t totalW = static_cast<int32_t>(gameCount) * dotSize + static_cast<int32_t>(gameCount - 1) * gap;
const int32_t startX = (screenWidth - totalW) / 2;
const int32_t y = screenHeight - 42;
for (size_t i = 0; i < gameCount; ++i)
{
const bool active = i == currentIndex || (is_animating() && i == targetIndex);
const RenderData::Color color = active ? RenderData::Color(250, 230, 145, 255) : RenderData::Color(90, 100, 128, 255);
ctx.fill_rect(startX + static_cast<int32_t>(i) * (dotSize + gap), y, dotSize, dotSize, color);
}
}
void GameCarousel::draw(Core::DrawContext& ctx, const RenderData::BitmapFont& font) const
{
ctx.clear_color(RenderData::Color(10, 14, 24, 255));
ctx.fill_rect(0, 0, screenWidth, 56, RenderData::Color(16, 22, 35, 255));
draw_text_centered(ctx, font, 20, "IMX6U GAME DESKTOP");
if (gameCount == 0)
{
draw_text_centered(ctx, font, screenHeight / 2, "NO GAMES");
return;
}
draw_edge_hints(ctx);
const int32_t baseX = get_card_x();
const int32_t baseY = get_card_y();
const int32_t offset = get_current_offset();
if (is_animating())
{
const int32_t currentOffset = animationFromOffset > 0 ? offset - screenWidth : offset + screenWidth;
draw_card(ctx, games[currentIndex], baseX + currentOffset, baseY, false);
draw_card(ctx, games[targetIndex], baseX + offset, baseY, true);
}
else
{
if (dragging && gameCount > 1)
{
if (offset > 0)
{
draw_card(ctx, games[previous_index(currentIndex)], baseX + offset - screenWidth, baseY, false);
}
else if (offset < 0)
{
draw_card(ctx, games[next_index(currentIndex)], baseX + offset + screenWidth, baseY, false);
}
}
draw_card(ctx, games[currentIndex], baseX + offset, baseY, true);
}
draw_text_centered(ctx, font, screenHeight - 78, games[currentIndex].title);
draw_page_dots(ctx);
}
const GameEntry* GameCarousel::get_current_game() const
{
if (games == nullptr || gameCount == 0)
{
return nullptr;
}
return &games[currentIndex];
}
}
+69
View File
@@ -0,0 +1,69 @@
#pragma once
#include <cstddef>
#include <cstdint>
#include "BitmapFont.h"
#include "GameCatalog.h"
namespace Core
{
class DrawContext;
}
namespace Platform
{
class IPointerInput;
}
namespace Desktop
{
enum class CarouselAction
{
None,
Launch
};
class GameCarousel
{
private:
const GameEntry* games;
size_t gameCount;
size_t currentIndex;
size_t targetIndex;
int32_t screenWidth;
int32_t screenHeight;
int32_t dragStartX;
int32_t dragCurrentX;
int32_t dragStartY;
bool dragging;
bool pointerWasDown;
int32_t animationFromOffset;
int32_t animationToOffset;
uint32_t animationElapsedMs;
uint32_t animationDurationMs;
size_t previous_index(size_t index) const;
size_t next_index(size_t index) const;
int32_t get_card_width() const;
int32_t get_card_height() const;
int32_t get_card_x() const;
int32_t get_card_y() const;
int32_t get_current_offset() const;
bool is_animating() const;
bool is_inside_card(int32_t x, int32_t y) const;
void begin_slide(size_t index, int32_t direction);
void draw_card(Core::DrawContext& ctx, const GameEntry& game, int32_t x, int32_t y, bool active) const;
void draw_text_centered(Core::DrawContext& ctx, const RenderData::BitmapFont& font, int32_t y, const char* text) const;
void draw_edge_hints(Core::DrawContext& ctx) const;
void draw_page_dots(Core::DrawContext& ctx) const;
public:
GameCarousel();
void configure(const GameEntry* catalog, size_t catalogCount, int32_t width, int32_t height);
CarouselAction update(uint32_t deltaMs, Platform::IPointerInput* pointerInput);
void draw(Core::DrawContext& ctx, const RenderData::BitmapFont& font) const;
const GameEntry* get_current_game() const;
};
}
+25
View File
@@ -0,0 +1,25 @@
#include "GameCatalog.h"
#include "desktop_atlas.h"
namespace Desktop
{
namespace
{
const GameEntry Games[] = {
{ "tom", "TomGame", &DesktopAtlas::TomGame, "IMX6U-Game", true },
{ "light", "LightGame", &DesktopAtlas::LightGame, "IMX6U-LightGame", true },
{ "demo", "Demo", &DesktopAtlas::Demo, "IMX6U-Demo", true },
};
}
const GameEntry* GetGameCatalog()
{
return Games;
}
size_t GetGameCatalogCount()
{
return sizeof(Games) / sizeof(Games[0]);
}
}
+20
View File
@@ -0,0 +1,20 @@
#pragma once
#include <cstddef>
#include "Sprite.h"
namespace Desktop
{
struct GameEntry
{
const char* id;
const char* title;
const RenderData::Sprite* cover;
const char* executable;
bool enabled;
};
const GameEntry* GetGameCatalog();
size_t GetGameCatalogCount();
}
+135
View File
@@ -0,0 +1,135 @@
#include "GameLauncher.h"
#include <cstdlib>
#include <iostream>
#include <string>
#ifdef _WIN32
#include <direct.h>
#include <windows.h>
#include <process.h>
#else
#include <errno.h>
#include <limits.h>
#include <sys/wait.h>
#include <sys/types.h>
#include <unistd.h>
#endif
namespace Desktop
{
namespace
{
static bool HasExeExtension(const std::string& command)
{
if (command.size() < 4)
{
return false;
}
const size_t offset = command.size() - 4;
return (command[offset] == '.') &&
(command[offset + 1] == 'e' || command[offset + 1] == 'E') &&
(command[offset + 2] == 'x' || command[offset + 2] == 'X') &&
(command[offset + 3] == 'e' || command[offset + 3] == 'E');
}
static std::string BuildExecutableName(const char* executable)
{
std::string command = executable ? executable : "";
#ifdef _WIN32
if (!HasExeExtension(command))
{
command += ".exe";
}
#endif
return command;
}
static std::string GetExecutableDirectory()
{
#ifdef _WIN32
char path[MAX_PATH];
const DWORD length = GetModuleFileNameA(nullptr, path, MAX_PATH);
if (length == 0 || length >= MAX_PATH)
{
char cwd[1024];
return _getcwd(cwd, sizeof(cwd)) == nullptr ? std::string(".") : std::string(cwd);
}
std::string fullPath(path, length);
const size_t slash = fullPath.find_last_of("\\/");
return slash == std::string::npos ? std::string(".") : fullPath.substr(0, slash);
#else
char path[PATH_MAX];
const ssize_t length = readlink("/proc/self/exe", path, sizeof(path) - 1);
if (length <= 0)
{
char cwd[PATH_MAX];
return getcwd(cwd, sizeof(cwd)) == nullptr ? std::string(".") : std::string(cwd);
}
path[length] = '\0';
std::string fullPath(path);
const size_t slash = fullPath.find_last_of('/');
return slash == std::string::npos ? std::string(".") : fullPath.substr(0, slash);
#endif
}
static std::string BuildLocalExecutablePath(const std::string& executable)
{
#ifdef _WIN32
return GetExecutableDirectory() + "\\" + executable;
#else
return GetExecutableDirectory() + "/" + executable;
#endif
}
}
bool GameLauncher::launch_and_wait(const GameEntry& game) const
{
if (!game.enabled || game.executable == nullptr || game.executable[0] == '\0')
{
std::cerr << "[WARN] Game is not launchable: " << (game.title ? game.title : "(unnamed)") << std::endl;
return false;
}
const std::string executable = BuildExecutableName(game.executable);
const std::string localPath = BuildLocalExecutablePath(executable);
#ifdef _WIN32
intptr_t result = _spawnl(_P_WAIT, localPath.c_str(), executable.c_str(), nullptr);
if (result == -1)
{
result = _spawnlp(_P_WAIT, executable.c_str(), executable.c_str(), nullptr);
}
if (result == -1)
{
std::cerr << "[WARN] Failed to launch " << executable << std::endl;
return false;
}
return true;
#else
const pid_t pid = fork();
if (pid < 0)
{
std::cerr << "[WARN] fork failed for " << executable << std::endl;
return false;
}
if (pid == 0)
{
execl(localPath.c_str(), executable.c_str(), static_cast<char*>(nullptr));
execlp(executable.c_str(), executable.c_str(), static_cast<char*>(nullptr));
std::exit(127);
}
int status = 0;
while (waitpid(pid, &status, 0) < 0)
{
if (errno != EINTR)
{
std::cerr << "[WARN] waitpid failed for " << executable << std::endl;
return false;
}
}
return true;
#endif
}
}
+12
View File
@@ -0,0 +1,12 @@
#pragma once
#include "GameCatalog.h"
namespace Desktop
{
class GameLauncher
{
public:
bool launch_and_wait(const GameEntry& game) const;
};
}
@@ -0,0 +1,439 @@
#include <SDL.h>
#include <SDL_image.h>
#include <algorithm>
#include <cstdint>
#include <cstring>
#include <fstream>
#include <iomanip>
#include <iostream>
#include <string>
#include <vector>
namespace
{
const char* InputDirectory = "src/Apps/Desktop/assets/raw";
const char* OutputHeaderPath = "src/Apps/Desktop/generated/desktop_atlas.h";
const int32_t AtlasWidth = 1024;
const int32_t AtlasPadding = 1;
const int32_t CoverMaxWidth = 860;
const int32_t CoverMaxHeight = 420;
struct SourceSpec
{
const char* file_name;
const char* region_name;
bool allow_generated_fallback;
};
const SourceSpec Sources[] = {
{ "TomGame.png", "TomGame", false },
{ "LightGame.png", "LightGame", false },
{ "Demo.png", "Demo", true },
};
struct Image
{
std::vector<uint16_t> pixels;
int32_t width;
int32_t height;
Image() : width(0), height(0) {}
bool is_valid() const
{
return width > 0 && height > 0 && !pixels.empty();
}
};
struct AtlasRegion
{
const SourceSpec* source;
Image image;
int32_t x;
int32_t y;
AtlasRegion() : source(nullptr), x(0), y(0) {}
};
static std::string JoinPath(const std::string& directory, const std::string& fileName)
{
if (directory.empty())
{
return fileName;
}
const char last = directory[directory.size() - 1];
if (last == '/' || last == '\\')
{
return directory + fileName;
}
return directory + "/" + fileName;
}
static uint16_t PackRgba5551(uint8_t r, uint8_t g, uint8_t b, uint8_t a)
{
return static_cast<uint16_t>(
((static_cast<uint16_t>(r) >> 3) << 11) |
((static_cast<uint16_t>(g) >> 3) << 6) |
((static_cast<uint16_t>(b) >> 3) << 1) |
(a ? 1u : 0u));
}
static Uint32 ReadSurfacePixel(const SDL_Surface* surface, int32_t x, int32_t y)
{
const uint8_t* row = static_cast<const uint8_t*>(surface->pixels) + static_cast<size_t>(y) * static_cast<size_t>(surface->pitch);
const uint8_t* src = row + static_cast<size_t>(x) * static_cast<size_t>(surface->format->BytesPerPixel);
switch (surface->format->BytesPerPixel)
{
case 1:
return src[0];
case 2:
{
uint16_t pixel = 0;
std::memcpy(&pixel, src, sizeof(pixel));
return static_cast<Uint32>(pixel);
}
case 3:
if (SDL_BYTEORDER == SDL_BIG_ENDIAN)
{
return (static_cast<Uint32>(src[0]) << 16) | (static_cast<Uint32>(src[1]) << 8) | static_cast<Uint32>(src[2]);
}
return static_cast<Uint32>(src[0]) | (static_cast<Uint32>(src[1]) << 8) | (static_cast<Uint32>(src[2]) << 16);
case 4:
{
Uint32 pixel = 0;
std::memcpy(&pixel, src, sizeof(pixel));
return pixel;
}
default:
return 0;
}
}
static bool LoadPngImage(const std::string& path, Image& image)
{
SDL_Surface* surface = IMG_Load(path.c_str());
if (surface == nullptr)
{
std::cerr << "IMG_Load failed: " << path << " : " << IMG_GetError() << std::endl;
return false;
}
if (SDL_LockSurface(surface) != 0)
{
std::cerr << "SDL_LockSurface failed: " << SDL_GetError() << std::endl;
SDL_FreeSurface(surface);
return false;
}
image.width = surface->w;
image.height = surface->h;
image.pixels.assign(static_cast<size_t>(image.width) * static_cast<size_t>(image.height), 0);
for (int32_t y = 0; y < image.height; ++y)
{
for (int32_t x = 0; x < image.width; ++x)
{
uint8_t r = 0;
uint8_t g = 0;
uint8_t b = 0;
uint8_t a = 0;
SDL_GetRGBA(ReadSurfacePixel(surface, x, y), surface->format, &r, &g, &b, &a);
image.pixels[static_cast<size_t>(y) * static_cast<size_t>(image.width) + static_cast<size_t>(x)] =
a == 0 ? 0u : PackRgba5551(r, g, b, a);
}
}
SDL_UnlockSurface(surface);
SDL_FreeSurface(surface);
return image.is_valid();
}
static Image ResizeNearest(const Image& source, int32_t width, int32_t height)
{
Image result;
if (!source.is_valid() || width <= 0 || height <= 0)
{
return result;
}
result.width = width;
result.height = height;
result.pixels.assign(static_cast<size_t>(width) * static_cast<size_t>(height), 0);
for (int32_t y = 0; y < height; ++y)
{
const int32_t sourceY = y * source.height / height;
for (int32_t x = 0; x < width; ++x)
{
const int32_t sourceX = x * source.width / width;
result.pixels[static_cast<size_t>(y) * static_cast<size_t>(width) + static_cast<size_t>(x)] =
source.pixels[static_cast<size_t>(sourceY) * static_cast<size_t>(source.width) + static_cast<size_t>(sourceX)];
}
}
return result;
}
static Image ResizeToFit(const Image& source, int32_t maxWidth, int32_t maxHeight)
{
if (!source.is_valid() || maxWidth <= 0 || maxHeight <= 0)
{
return Image();
}
const float scaleX = static_cast<float>(maxWidth) / static_cast<float>(source.width);
const float scaleY = static_cast<float>(maxHeight) / static_cast<float>(source.height);
const float scale = std::min(scaleX, scaleY);
const int32_t width = std::max(1, static_cast<int32_t>(static_cast<float>(source.width) * scale));
const int32_t height = std::max(1, static_cast<int32_t>(static_cast<float>(source.height) * scale));
return ResizeNearest(source, width, height);
}
static Image GenerateDemoCover()
{
Image image;
image.width = CoverMaxWidth;
image.height = CoverMaxHeight;
image.pixels.assign(static_cast<size_t>(image.width) * static_cast<size_t>(image.height), 0u);
for (int32_t y = 0; y < image.height; ++y)
{
for (int32_t x = 0; x < image.width; ++x)
{
const uint8_t r = static_cast<uint8_t>(24 + (x * 48) / image.width);
const uint8_t g = static_cast<uint8_t>(36 + (y * 72) / image.height);
const uint8_t b = static_cast<uint8_t>(80 + ((x + y) * 64) / (image.width + image.height));
image.pixels[static_cast<size_t>(y) * static_cast<size_t>(image.width) + static_cast<size_t>(x)] =
PackRgba5551(r, g, b, 255);
}
}
const int32_t boxX = image.width / 2 - 170;
const int32_t boxY = image.height / 2 - 62;
for (int32_t y = 0; y < 124; ++y)
{
for (int32_t x = 0; x < 340; ++x)
{
const bool border = x < 6 || y < 6 || x >= 334 || y >= 118;
const uint16_t pixel = border ? PackRgba5551(250, 230, 145, 255) : PackRgba5551(18, 24, 42, 255);
const int32_t dstX = boxX + x;
const int32_t dstY = boxY + y;
if (dstX >= 0 && dstX < image.width && dstY >= 0 && dstY < image.height)
{
image.pixels[static_cast<size_t>(dstY) * static_cast<size_t>(image.width) + static_cast<size_t>(dstX)] = pixel;
}
}
}
return image;
}
static bool LoadAndResizeSource(const SourceSpec& source, AtlasRegion& region)
{
region.source = &source;
Image image;
const std::string inputPath = JoinPath(InputDirectory, source.file_name);
if (!LoadPngImage(inputPath, image))
{
if (!source.allow_generated_fallback)
{
return false;
}
region.image = GenerateDemoCover();
std::cout << "(generated fallback) -> " << source.region_name << " ("
<< region.image.width << "x" << region.image.height << ")" << std::endl;
return region.image.is_valid();
}
region.image = ResizeToFit(image, CoverMaxWidth, CoverMaxHeight);
if (!region.image.is_valid())
{
std::cerr << "Resize failed: " << inputPath << std::endl;
return false;
}
std::cout << source.file_name << " -> " << source.region_name << " ("
<< region.image.width << "x" << region.image.height << ")" << std::endl;
return true;
}
static bool PackAtlas(std::vector<AtlasRegion>& regions, int32_t& atlasHeight)
{
int32_t cursorX = 0;
int32_t cursorY = 0;
int32_t rowHeight = 0;
for (size_t i = 0; i < regions.size(); ++i)
{
AtlasRegion& region = regions[i];
if (region.image.width > AtlasWidth)
{
std::cerr << "AtlasWidth is smaller than source image: " << region.source->file_name << std::endl;
return false;
}
if (cursorX > 0 && cursorX + region.image.width > AtlasWidth)
{
cursorY += rowHeight + AtlasPadding;
cursorX = 0;
rowHeight = 0;
}
region.x = cursorX;
region.y = cursorY;
cursorX += region.image.width + AtlasPadding;
rowHeight = std::max(rowHeight, region.image.height);
}
atlasHeight = cursorY + rowHeight;
return atlasHeight > 0;
}
static std::vector<uint16_t> BuildAtlasPixels(const std::vector<AtlasRegion>& regions, int32_t atlasHeight)
{
std::vector<uint16_t> atlasPixels(static_cast<size_t>(AtlasWidth) * static_cast<size_t>(atlasHeight), 0u);
for (size_t i = 0; i < regions.size(); ++i)
{
const AtlasRegion& region = regions[i];
for (int32_t y = 0; y < region.image.height; ++y)
{
const size_t srcOffset = static_cast<size_t>(y) * static_cast<size_t>(region.image.width);
const size_t dstOffset = static_cast<size_t>(region.y + y) * static_cast<size_t>(AtlasWidth) + static_cast<size_t>(region.x);
std::copy(
region.image.pixels.begin() + static_cast<std::vector<uint16_t>::difference_type>(srcOffset),
region.image.pixels.begin() + static_cast<std::vector<uint16_t>::difference_type>(srcOffset + static_cast<size_t>(region.image.width)),
atlasPixels.begin() + static_cast<std::vector<uint16_t>::difference_type>(dstOffset));
}
}
return atlasPixels;
}
static void WritePixel5551(std::ofstream& file, uint16_t pixel)
{
file << "0x"
<< std::uppercase
<< std::hex
<< std::setw(4)
<< std::setfill('0')
<< pixel
<< std::dec
<< std::nouppercase
<< std::setfill(' ');
}
static bool WriteAtlasHeader(const std::vector<AtlasRegion>& regions, const std::vector<uint16_t>& atlasPixels, int32_t atlasHeight)
{
std::ofstream file(OutputHeaderPath, std::ios::binary);
if (!file.good())
{
std::cerr << "Open output header failed: " << OutputHeaderPath << std::endl;
return false;
}
file << "// Auto-generated by DesktopAssetTool.cpp\n";
file << "#pragma once\n";
file << "#include <cstdint>\n";
file << "#include \"Image.h\"\n";
file << "#include \"Sprite.h\"\n\n";
file << "namespace DesktopAtlas\n";
file << "{\n";
file << "\tstatic const int32_t desktop_atlas_width = " << AtlasWidth << ";\n";
file << "\tstatic const int32_t desktop_atlas_height = " << atlasHeight << ";\n\n";
file << "\tstatic const uint16_t desktop_atlas_pixels[] = {\n";
for (size_t i = 0; i < atlasPixels.size(); i += 12)
{
file << "\t\t";
const size_t end = std::min(i + 12, atlasPixels.size());
for (size_t j = i; j < end; ++j)
{
if (j > i)
{
file << " ";
}
WritePixel5551(file, atlasPixels[j]);
if (j + 1 < atlasPixels.size())
{
file << ",";
}
}
file << "\n";
}
file << "\t};\n\n";
file << "\tstatic const RenderData::Image image(desktop_atlas_pixels, desktop_atlas_width, desktop_atlas_height, 0x0000, RenderData::PixelFormat::RGBA5551);\n\n";
for (size_t i = 0; i < regions.size(); ++i)
{
const AtlasRegion& region = regions[i];
file << "\tstatic const RenderData::Sprite "
<< region.source->region_name
<< "(&image, "
<< region.x << ", "
<< region.y << ", "
<< region.image.width << ", "
<< region.image.height << ");\n";
}
file << "}\n";
return file.good();
}
static bool GenerateDesktopAtlasHeader()
{
std::vector<AtlasRegion> regions;
const size_t sourceCount = sizeof(Sources) / sizeof(Sources[0]);
regions.reserve(sourceCount);
for (size_t i = 0; i < sourceCount; ++i)
{
AtlasRegion region;
if (!LoadAndResizeSource(Sources[i], region))
{
return false;
}
regions.push_back(region);
}
int32_t atlasHeight = 0;
if (!PackAtlas(regions, atlasHeight))
{
return false;
}
const std::vector<uint16_t> atlasPixels = BuildAtlasPixels(regions, atlasHeight);
if (!WriteAtlasHeader(regions, atlasPixels, atlasHeight))
{
return false;
}
std::cout << "Generated " << OutputHeaderPath << " (" << AtlasWidth << "x" << atlasHeight << ", "
<< regions.size() << " regions)" << std::endl;
return true;
}
}
int main(int argc, char* argv[])
{
if (argc > 1)
{
std::cout << "Usage: DesktopAssetTool\n";
std::cout << "Generates " << OutputHeaderPath << " from PNG files in " << InputDirectory << "." << std::endl;
return 1;
}
const int imageFlags = IMG_INIT_PNG;
if ((IMG_Init(imageFlags) & imageFlags) != imageFlags)
{
std::cerr << "IMG_Init failed: " << IMG_GetError() << std::endl;
return 1;
}
const bool ok = GenerateDesktopAtlasHeader();
IMG_Quit();
return ok ? 0 : 1;
}
+103 -53
View File
@@ -1,101 +1,151 @@
set(TOM_GAME_TARGET IMX6U-Game)
set(TOM_ATLAS_HEADER "${CMAKE_CURRENT_SOURCE_DIR}/generated/tom_atlas.h")
set_source_files_properties(${TOM_ATLAS_HEADER} PROPERTIES GENERATED TRUE)
set(TOM_AUDIO_GENERATED_DIR "${CMAKE_CURRENT_BINARY_DIR}/generated")
set(TOM_AUDIO_HEADER "${TOM_AUDIO_GENERATED_DIR}/TomAudioData.h")
set(TOM_AUDIO_SOURCE "${TOM_AUDIO_GENERATED_DIR}/TomAudioData.cpp")
add_custom_command(
OUTPUT ${TOM_AUDIO_HEADER} ${TOM_AUDIO_SOURCE}
COMMAND ${CMAKE_COMMAND}
"-DOPEN_LIGHT_WAV=${CMAKE_CURRENT_SOURCE_DIR}/assets/audio/OpenLight.wav"
"-DCLOSE_LIGHT_WAV=${CMAKE_CURRENT_SOURCE_DIR}/assets/audio/CloseLight.wav"
"-DOUTPUT_HEADER=${TOM_AUDIO_HEADER}"
"-DOUTPUT_SOURCE=${TOM_AUDIO_SOURCE}"
-P "${CMAKE_CURRENT_SOURCE_DIR}/tools/EmbedTomAudio.cmake"
DEPENDS
"${CMAKE_CURRENT_SOURCE_DIR}/assets/audio/OpenLight.wav"
"${CMAKE_CURRENT_SOURCE_DIR}/assets/audio/CloseLight.wav"
"${CMAKE_CURRENT_SOURCE_DIR}/tools/EmbedTomAudio.cmake"
COMMENT "Embedding Tom voice response WAV files"
VERBATIM
)
set_source_files_properties(${TOM_AUDIO_HEADER} ${TOM_AUDIO_SOURCE} PROPERTIES GENERATED TRUE)
add_executable(${TOM_GAME_TARGET}
Main.cpp
generated/tom_atlas.h
src/app/TomGameApp.cpp
src/audio/VoiceEffect.cpp
src/audio/VoicePlayer.cpp
src/audio/VoiceRecorder.cpp
src/audio/WavAudioData.cpp
src/gameplay/KeywordCommandRouter.cpp
src/gameplay/SpriteButton.cpp
src/gameplay/TomAnimator.cpp
src/gameplay/TomHud.cpp
src/gameplay/VoiceInteractionController.cpp
src/recognition/KeywordRecognizer.cpp
src/recognition/ResBnKwsCnRecognizer.cpp
src/recognition/ResBnKwsCnModelData.cpp
src/ui/TomSettingsPanel.cpp
${TOM_ATLAS_HEADER}
${TOM_AUDIO_HEADER}
${TOM_AUDIO_SOURCE}
)
target_include_directories(${TOM_GAME_TARGET} PRIVATE
${CMAKE_CURRENT_SOURCE_DIR}/src
${CMAKE_CURRENT_SOURCE_DIR}/src/app
${CMAKE_CURRENT_SOURCE_DIR}/src/audio
${CMAKE_CURRENT_SOURCE_DIR}/src/gameplay
${CMAKE_CURRENT_SOURCE_DIR}/src/recognition
${CMAKE_CURRENT_SOURCE_DIR}/src/ui
${CMAKE_CURRENT_SOURCE_DIR}/generated
${TOM_AUDIO_GENERATED_DIR}
)
imx6u_configure_app_target(${TOM_GAME_TARGET})
if(NOT USE_FRAMEBUFFER)
set(SPRITE_ASSET_TOOL_SOURCES
if(BUILD_TESTING AND NOT TARGET_IMX)
add_executable(TomAudioDataTests EXCLUDE_FROM_ALL
tests/TomAudioDataTests.cpp
src/audio/WavAudioData.cpp
src/gameplay/KeywordCommandRouter.cpp
src/recognition/KeywordRecognizer.cpp
${TOM_AUDIO_HEADER}
${TOM_AUDIO_SOURCE}
)
target_include_directories(TomAudioDataTests PRIVATE
${CMAKE_CURRENT_SOURCE_DIR}/src/audio
${CMAKE_CURRENT_SOURCE_DIR}/src/gameplay
${CMAKE_CURRENT_SOURCE_DIR}/src/recognition
${TOM_AUDIO_GENERATED_DIR}
)
add_test(NAME tom_audio_data COMMAND TomAudioDataTests)
endif()
if(CMAKE_CROSSCOMPILING AND NOT EXISTS "${TOM_ATLAS_HEADER}")
message(FATAL_ERROR
"Tom atlas header is missing. Run GenerateTomAtlasHeader in a host build before cross compiling."
)
endif()
if(NOT TARGET_IMX)
set(TOM_ATLAS_TOOL_SOURCES
tools/asset_pipeline/SpriteAssetTool.cpp
${PROJECT_SOURCE_DIR}/src/Core/Asset/SpriteAssetLoader.cpp
)
if(WIN32)
add_executable(TomSpriteAssetTool EXCLUDE_FROM_ALL ${SPRITE_ASSET_TOOL_SOURCES})
set_target_properties(TomSpriteAssetTool PROPERTIES OUTPUT_NAME SpriteAssetTool)
target_include_directories(TomSpriteAssetTool PRIVATE
${PROJECT_SOURCE_DIR}/src/Core/Asset
${PROJECT_SOURCE_DIR}/src/Core/RenderData
add_executable(TomAtlasTool EXCLUDE_FROM_ALL ${TOM_ATLAS_TOOL_SOURCES})
target_include_directories(TomAtlasTool PRIVATE
${PROJECT_SOURCE_DIR}/libs/Win/SDL2/include
${PROJECT_SOURCE_DIR}/libs/Win/SDL_image/include
)
target_link_directories(TomSpriteAssetTool PRIVATE
target_link_directories(TomAtlasTool PRIVATE
${SDL2_LIB_DIR}
${SDL2_IMAGE_LIB_DIR}
)
target_link_libraries(TomSpriteAssetTool PRIVATE SDL2main SDL2 SDL2_image)
target_link_libraries(TomAtlasTool PRIVATE SDL2main SDL2 SDL2_image)
add_custom_command(TARGET TomSpriteAssetTool POST_BUILD
add_custom_command(TARGET TomAtlasTool POST_BUILD
COMMAND ${CMAKE_COMMAND} -E copy_if_different
"${SDL2_DLL}"
"$<TARGET_FILE_DIR:TomSpriteAssetTool>"
"$<TARGET_FILE_DIR:TomAtlasTool>"
)
add_custom_command(TARGET TomSpriteAssetTool POST_BUILD
add_custom_command(TARGET TomAtlasTool POST_BUILD
COMMAND ${CMAKE_COMMAND} -E copy_if_different
"${SDL2_IMAGE_DLL}"
"$<TARGET_FILE_DIR:TomSpriteAssetTool>"
"$<TARGET_FILE_DIR:TomAtlasTool>"
)
elseif(SDL2_image_FOUND)
add_executable(TomSpriteAssetTool EXCLUDE_FROM_ALL ${SPRITE_ASSET_TOOL_SOURCES})
set_target_properties(TomSpriteAssetTool PROPERTIES OUTPUT_NAME SpriteAssetTool)
target_include_directories(TomSpriteAssetTool PRIVATE
${PROJECT_SOURCE_DIR}/src/Core/Asset
${PROJECT_SOURCE_DIR}/src/Core/RenderData
add_executable(TomAtlasTool EXCLUDE_FROM_ALL ${TOM_ATLAS_TOOL_SOURCES})
target_include_directories(TomAtlasTool PRIVATE
${SDL2_image_INCLUDE_DIRS}
${SDL2_IMAGE_INCLUDE_DIRS}
)
target_link_libraries(TomSpriteAssetTool PRIVATE SDL2::SDL2)
target_link_libraries(TomAtlasTool PRIVATE SDL2::SDL2)
if(TARGET SDL2_image::SDL2_image)
target_link_libraries(TomSpriteAssetTool PRIVATE SDL2_image::SDL2_image)
target_link_libraries(TomAtlasTool PRIVATE SDL2_image::SDL2_image)
else()
target_link_libraries(TomSpriteAssetTool PRIVATE
target_link_libraries(TomAtlasTool PRIVATE
${SDL2_image_LIBRARIES}
${SDL2_IMAGE_LIBRARIES}
)
endif()
else()
message(STATUS "SpriteAssetTool disabled: SDL2_image was not found")
message(STATUS "TomAtlasTool disabled: SDL2_image was not found")
endif()
if(TARGET TomSpriteAssetTool)
add_custom_target(ConvertTomSprites
COMMAND $<TARGET_FILE:TomSpriteAssetTool>
--batch
"src/Apps/Game/assets/raw"
"src/Apps/Game/assets/sprites"
--preset tom-800x480
WORKING_DIRECTORY "${PROJECT_SOURCE_DIR}"
DEPENDS TomSpriteAssetTool
COMMENT "Converting Tom PNG assets to board-ready .sprite files"
VERBATIM
)
add_custom_target(GenerateTomAtlasHeader
COMMAND $<TARGET_FILE:TomSpriteAssetTool>
--atlas-header
"src/Apps/Game/assets/raw"
"src/Apps/Game/generated/tom_atlas.h"
tom_atlas
--atlas-width
1024
--preset
tom-800x480
WORKING_DIRECTORY "${PROJECT_SOURCE_DIR}"
DEPENDS TomSpriteAssetTool
COMMENT "Generating Tom atlas header from PNG assets"
VERBATIM
)
if(TARGET TomAtlasTool)
if(NOT CMAKE_CROSSCOMPILING)
add_custom_target(GenerateTomAtlasHeader
COMMAND ${CMAKE_COMMAND} -E make_directory
"src/Apps/Game/generated"
COMMAND $<TARGET_FILE:TomAtlasTool>
WORKING_DIRECTORY "${PROJECT_SOURCE_DIR}"
DEPENDS TomAtlasTool
COMMENT "Generating Tom atlas header from PNG assets"
VERBATIM
)
add_dependencies(${TOM_GAME_TARGET} GenerateTomAtlasHeader)
else()
add_custom_target(GenerateTomAtlasHeader
COMMAND ${CMAKE_COMMAND} -E echo
"GenerateTomAtlasHeader is host-only. Run it in build-win or build-linux before cross compiling."
VERBATIM
)
endif()
endif()
endif()
+281 -63
View File
@@ -1,21 +1,24 @@
#include <algorithm>
#include <chrono>
#include <cstdint>
#include <cstdlib>
#include <cstring>
#include <iostream>
#include <string>
#include <thread>
#include "Color.h"
#include "Display.h"
#include "DefaultHardware.h"
#include "IDisplay.h"
#include "DrawContext.h"
#include "TimeSource.h"
#include "Timer.h"
#include "tom_atlas.h"
#include "app/TomGameApp.h"
#include "recognition/KeywordRecognizer.h"
#include "recognition/ResBnKwsCnRecognizer.h"
#ifdef USE_FRAMEBUFFER
#ifdef TARGET_IMX
#include "FBDisplay.h"
#else
#endif
#ifdef TARGET_PC
#include "SDLDisplay.h"
#endif
@@ -27,39 +30,67 @@ namespace
struct ProgramOptions
{
uint32_t target_fps;
uint32_t audio_sample_rate;
uint32_t audio_channels;
float kws_threshold;
float kws_input_gain;
std::string audio_input_device;
std::string audio_output_device;
std::string settings_path;
bool show_help;
ProgramOptions()
: target_fps(Core::Timer::DefaultFps),
audio_sample_rate(16000),
audio_channels(1),
kws_threshold(0.75f),
kws_input_gain(1.0f),
audio_input_device("default"),
audio_output_device("default"),
#ifdef TARGET_IMX
settings_path("/opt/imx6u-game/tom_settings.cfg"),
#else
settings_path("tom_settings.cfg"),
#endif
show_help(false)
{
}
};
static Platform::IDisplay* CreateDisplay()
static Platform::IDisplay *CreateDisplay()
{
#ifdef USE_FRAMEBUFFER
#ifdef TARGET_IMX
return new Platform::FBDisplay();
#else
#endif
#ifdef TARGET_PC
return new Platform::SDLDisplay();
#endif
}
static void PrintUsage(const char* program_name)
static void PrintUsage(const char *program_name)
{
std::cout
<< "Usage: " << program_name << " [--fps 30|45|60]\n"
<< " " << program_name << " [--fps=30|45|60]\n";
<< "Usage: " << program_name << " [--fps 30|45|60] [--kws-threshold 0.75] [--kws-input-gain 1.0]\n"
<< " " << program_name << " [--audio-input-device default] [--audio-output-device default]\n"
<< " " << program_name << " [--audio-rate 16000] [--audio-channels 1]\n"
<< " " << program_name << " [--settings-path tom_settings.cfg]\n";
}
static ProgramOptions ParseProgramOptions(int argc, char* argv[])
static ProgramOptions ParseProgramOptions(int argc, char *argv[])
{
ProgramOptions options;
for (int i = 1; i < argc; ++i)
{
const char* arg = argv[i];
const char* fps_value = nullptr;
const char *arg = argv[i];
const char *fps_value = nullptr;
const char *audio_rate_value = nullptr;
const char *audio_channels_value = nullptr;
const char *kws_threshold_value = nullptr;
const char *kws_input_gain_value = nullptr;
const char *audio_input_device_value = nullptr;
const char *audio_output_device_value = nullptr;
const char *settings_path_value = nullptr;
if (std::strcmp(arg, "--help") == 0 || std::strcmp(arg, "-h") == 0)
{
@@ -81,6 +112,120 @@ namespace
{
fps_value = arg + 6;
}
else if (std::strcmp(arg, "--audio-rate") == 0)
{
if (i + 1 < argc)
{
audio_rate_value = argv[++i];
}
else
{
std::cerr << "Missing value for --audio-rate, using default 16000.\n";
}
}
else if (std::strncmp(arg, "--audio-rate=", 13) == 0)
{
audio_rate_value = arg + 13;
}
else if (std::strcmp(arg, "--audio-channels") == 0)
{
if (i + 1 < argc)
{
audio_channels_value = argv[++i];
}
else
{
std::cerr << "Missing value for --audio-channels, using default 1.\n";
}
}
else if (std::strncmp(arg, "--audio-channels=", 17) == 0)
{
audio_channels_value = arg + 17;
}
else if (std::strcmp(arg, "--audio-input-device") == 0)
{
if (i + 1 < argc)
{
audio_input_device_value = argv[++i];
}
else
{
std::cerr << "Missing value for --audio-input-device, using default.\n";
}
}
else if (std::strncmp(arg, "--audio-input-device=", 21) == 0)
{
audio_input_device_value = arg + 21;
}
else if (std::strcmp(arg, "--audio-output-device") == 0)
{
if (i + 1 < argc)
{
audio_output_device_value = argv[++i];
}
else
{
std::cerr << "Missing value for --audio-output-device, using default.\n";
}
}
else if (std::strncmp(arg, "--audio-output-device=", 22) == 0)
{
audio_output_device_value = arg + 22;
}
else if (std::strcmp(arg, "--settings-path") == 0)
{
if (i + 1 < argc)
{
settings_path_value = argv[++i];
}
else
{
std::cerr << "Missing value for --settings-path, using default.\n";
}
}
else if (std::strncmp(arg, "--settings-path=", 16) == 0)
{
settings_path_value = arg + 16;
}
else if (std::strcmp(arg, "--kws-model") == 0 ||
std::strncmp(arg, "--kws-model=", 12) == 0)
{
std::cerr << "--kws-model is ignored: Tiny KWS weights are embedded in the executable.\n";
if (std::strcmp(arg, "--kws-model") == 0 && i + 1 < argc)
{
++i;
}
}
else if (std::strcmp(arg, "--kws-threshold") == 0)
{
if (i + 1 < argc)
{
kws_threshold_value = argv[++i];
}
else
{
std::cerr << "Missing value for --kws-threshold, using default 0.75.\n";
}
}
else if (std::strncmp(arg, "--kws-threshold=", 16) == 0)
{
kws_threshold_value = arg + 16;
}
else if (std::strcmp(arg, "--kws-input-gain") == 0)
{
if (i + 1 < argc)
{
kws_input_gain_value = argv[++i];
}
else
{
std::cerr << "Missing value for --kws-input-gain, using default 1.0.\n";
}
}
else if (std::strncmp(arg, "--kws-input-gain=", 17) == 0)
{
kws_input_gain_value = arg + 17;
}
if (fps_value != nullptr)
{
@@ -95,12 +240,85 @@ namespace
options.target_fps = Core::Timer::DefaultFps;
}
}
if (audio_rate_value != nullptr)
{
const uint32_t parsed_rate = static_cast<uint32_t>(std::strtoul(audio_rate_value, nullptr, 10));
if (parsed_rate > 0u)
{
options.audio_sample_rate = parsed_rate;
}
else
{
std::cerr << "Unsupported audio rate '" << audio_rate_value << "', using default 16000.\n";
options.audio_sample_rate = 16000;
}
}
if (audio_channels_value != nullptr)
{
const uint32_t parsed_channels = static_cast<uint32_t>(std::strtoul(audio_channels_value, nullptr, 10));
if (parsed_channels > 0u)
{
options.audio_channels = parsed_channels;
}
else
{
std::cerr << "Unsupported audio channels '" << audio_channels_value << "', using default 1.\n";
options.audio_channels = 1;
}
}
if (audio_input_device_value != nullptr && audio_input_device_value[0] != '\0')
{
options.audio_input_device = audio_input_device_value;
}
if (audio_output_device_value != nullptr && audio_output_device_value[0] != '\0')
{
options.audio_output_device = audio_output_device_value;
}
if (settings_path_value != nullptr && settings_path_value[0] != '\0')
{
options.settings_path = settings_path_value;
}
if (kws_threshold_value != nullptr)
{
char *threshold_end = nullptr;
const float parsed_threshold = std::strtof(kws_threshold_value, &threshold_end);
if (threshold_end != kws_threshold_value && *threshold_end == '\0' && parsed_threshold >= 0.0f)
{
options.kws_threshold = parsed_threshold;
}
else
{
std::cerr << "Unsupported KWS threshold '" << kws_threshold_value << "', using default 0.75.\n";
options.kws_threshold = 0.75f;
}
}
if (kws_input_gain_value != nullptr)
{
char *gain_end = nullptr;
const float parsed_gain = std::strtof(kws_input_gain_value, &gain_end);
if (gain_end != kws_input_gain_value && *gain_end == '\0' && parsed_gain > 0.0f)
{
options.kws_input_gain = parsed_gain;
}
else
{
std::cerr << "Unsupported KWS input gain '" << kws_input_gain_value << "', using default 1.0.\n";
options.kws_input_gain = 1.0f;
}
}
}
return options;
}
static void SleepRemainingFrameTime(const Core::Timer& timer, const Platform::ITimeSource& time_source)
static void SleepRemainingFrameTime(const Core::Timer &timer, const Platform::ITimeSource &time_source)
{
const uint32_t sleep_ms = timer.remaining_frame_ms(time_source.get_time_ms());
if (sleep_ms > 0u)
@@ -108,28 +326,9 @@ namespace
std::this_thread::sleep_for(std::chrono::milliseconds(sleep_ms));
}
}
static const RenderData::SpriteRegion& SelectTomFrame(
uint32_t animation_time_ms,
const RenderData::SpriteRegion* const* speaking_frames,
size_t speaking_frame_count)
{
const uint32_t phase = (animation_time_ms / 1000u) % 6u;
if (phase < 2u)
{
return TomAtlas::tom_stand;
}
if (phase < 3u)
{
return TomAtlas::tom_listhen;
}
const size_t frame_index = (animation_time_ms / 120u) % speaking_frame_count;
return *speaking_frames[frame_index];
}
}
int main(int argc, char* argv[])
int main(int argc, char *argv[])
{
const ProgramOptions options = ParseProgramOptions(argc, argv);
if (options.show_help)
@@ -138,35 +337,58 @@ int main(int argc, char* argv[])
return 0;
}
Platform::IDisplay* display = CreateDisplay();
Platform::IDisplay *display = CreateDisplay();
if (!display->init(ScreenWidth, ScreenHeight))
{
delete display;
return -1;
}
const RenderData::SpriteRegion* speaking_frames[] = {
&TomAtlas::tom_say1,
&TomAtlas::tom_say2,
&TomAtlas::tom_say3,
&TomAtlas::tom_say4,
&TomAtlas::tom_say3,
&TomAtlas::tom_say2
};
const size_t speaking_frame_count = sizeof(speaking_frames) / sizeof(speaking_frames[0]);
Platform::DefaultAudioInput audioInput;
Platform::DefaultAudioOutput audioOutput;
Platform::DefaultButtonInput buttonInput;
Platform::DefaultPointerInput pointerInput;
Platform::DefaultBoardLed boardLed;
Game::ResBnKwsCnRecognizer keywordRecognizer;
keywordRecognizer.set_confidence_threshold(options.kws_threshold);
keywordRecognizer.set_input_gain(options.kws_input_gain);
if (!buttonInput.init())
{
std::cerr << "[WARN] Button input init failed; physical key trigger is disabled." << std::endl;
}
if (!pointerInput.init("", ScreenWidth, ScreenHeight))
{
std::cerr << "[WARN] Pointer input init failed; on-screen button trigger is disabled." << std::endl;
}
if (!boardLed.init())
{
std::cerr << "[WARN] Board LED init failed; voice responses remain enabled." << std::endl;
}
Core::DrawContext ctx(ScreenWidth, ScreenHeight);
Core::Timer timer(options.target_fps);
Platform::SteadyTimeSource time_source;
Game::TomGameApp app(
ScreenWidth,
ScreenHeight,
&audioInput,
&audioOutput,
&buttonInput,
&pointerInput,
&boardLed,
&keywordRecognizer,
options.settings_path);
app.configure_audio(options.audio_sample_rate, options.audio_channels);
app.configure_audio_devices(options.audio_input_device, options.audio_output_device);
std::cout << "[INFO] Tom game started. Target FPS: " << timer.target_fps() << std::endl;
bool is_running = true;
uint32_t animation_time_ms = 0;
while (is_running)
{
timer.begin_frame(time_source.get_time_ms());
animation_time_ms += timer.fixed_delta_ms();
bool should_quit = false;
display->poll_events(should_quit);
@@ -175,26 +397,22 @@ int main(int argc, char* argv[])
is_running = false;
}
ctx.clear(RenderData::Color(18, 18, 24, 255));
ctx.draw_sprite_region(0, 0, TomAtlas::background);
const RenderData::SpriteRegion& tom = SelectTomFrame(
animation_time_ms,
speaking_frames,
speaking_frame_count);
const int32_t tom_x = (ScreenWidth - tom.width) / 2;
const int32_t tom_y = std::max(0, ScreenHeight - tom.height - 72);
ctx.draw_sprite_region(tom_x, tom_y, tom);
const int32_t button_x = (ScreenWidth - TomAtlas::ui_record.width) / 2;
const int32_t button_y = ScreenHeight - TomAtlas::ui_record.height - 16;
ctx.draw_sprite_region(button_x, button_y, TomAtlas::ui_record);
app.update(timer.fixed_delta_ms());
if (app.should_exit())
{
is_running = false;
continue;
}
app.draw(ctx);
ctx.present(display);
SleepRemainingFrameTime(timer, time_source);
}
pointerInput.shutdown();
buttonInput.shutdown();
audioInput.shutdown();
audioOutput.shutdown();
display->shutdown();
delete display;
return 0;
+381
View File
@@ -0,0 +1,381 @@
# TomGame、Desktop 与板端部署
本文说明 TomGame 当前的关键词识别方式,以及 Windows 验证、WSL
交叉编译、Windows SCP 部署和 IMX6ULL 板端运行流程。
TomGame 使用内嵌的 `ResBnKwsCnRecognizer` INT8 模型,不需要 Python、
TensorFlow、TensorFlow Lite 运行库或外部模型文件。
模型支持以下 14 个类别:
```text
Down, Go, Left, No, Off, On, Right, Stop, Up, Yes,
OpenLight, CloseLight, Silence, Unknown
```
当前游戏动作映射:
```text
Up / On -> Jump
Stop -> Idle
OpenLight -> 打开 ALPHA 板载 LED,播放 Tom 音效语音
CloseLight -> 关闭 ALPHA 板载 LED,播放 Tom 音效语音
```
`OpenLight.wav` 和 `CloseLight.wav` 会在构建阶段转换成只读 C++ 数据并
编译进可执行文件,板端部署时不需要额外复制 WAV 文件。板载 LED 使用
正点原子 I.MX6U-ALPHA 的 `GPIO1_IO03`,低电平点亮;程序优先使用
`/dev/gpioled` 等正点原子教学驱动设备,并兼容 GPIO sysfs。
## Windows 验证
Windows 版本和板端版本使用同一套 C++ 特征提取、INT8 模型推理和
多窗口融合逻辑,不再需要 Python 对照脚本。
在项目根目录构建 TomGame:
```powershell
cmake --build build --config Release --target IMX6U-Game
```
直接运行:
```powershell
.\build\Release\IMX6U-Game.exe `
--fps 30 `
--kws-threshold 0.75 `
--kws-input-gain 1.0
```
程序支持以下关键词识别参数:
```text
--kws-threshold 识别置信度阈值,默认 0.75
--kws-input-gain MFCC 输入增益,范围 (0, 1],默认 1.0
```
识别成功或未达到阈值时,控制台会打印类似日志:
```text
[INFO] ResBN-CN INT8 KWS result: class=Up, confidence=0.92, windows=...
```
Windows Desktop 和全部游戏可以一起构建:
```powershell
cmake --build build --config Release --target `
IMX6U-Desktop `
IMX6U-Game `
IMX6U-LightGame `
IMX6U-Demo
```
运行主页:
```powershell
.\build\Release\IMX6U-Desktop.exe
```
Desktop 会从自身所在目录寻找并启动以下程序:
```text
IMX6U-Game.exe
IMX6U-LightGame.exe
IMX6U-Demo.exe
```
因此 Desktop 和各游戏的可执行文件需要位于同一个目录。
## WSL 交叉编译
### 1. 安装工具链
首次编译时安装 ARM 交叉编译器和 ALSA 开发文件:
```bash
sudo dpkg --add-architecture armhf
sudo apt update
sudo apt install -y \
cmake \
build-essential \
gcc-arm-linux-gnueabihf \
g++-arm-linux-gnueabihf \
libasound2-dev:armhf
```
检查交叉编译器:
```bash
arm-linux-gnueabihf-g++ --version
```
### 2. 确认生成资源
交叉编译不会在板端生成 PNG 图集。开始编译前确认以下文件存在:
```bash
ls src/Apps/Game/generated/tom_atlas.h
ls src/Apps/Desktop/generated/desktop_atlas.h
```
如果文件不存在,先在 Windows 主机构建一次对应目标,生成图集后再同步到
WSL 源码目录。
### 3. 配置并编译
在 WSL 项目根目录执行:
```bash
rm -rf build-arm-fb
cmake -S . -B build-arm-fb \
-DCMAKE_TOOLCHAIN_FILE=cmake/toolchain-arm-linux-gnueabihf.cmake \
-DTARGET_IMX=ON \
-DCMAKE_BUILD_TYPE=Release
cmake --build build-arm-fb \
--target \
IMX6U-Desktop \
IMX6U-Game \
IMX6U-LightGame \
IMX6U-Demo \
-j"$(nproc)"
```
查找编译产物:
```bash
find build-arm-fb -type f \
\( -name IMX6U-Desktop \
-o -name IMX6U-Game \
-o -name IMX6U-LightGame \
-o -name IMX6U-Demo \)
```
使用 `file` 确认产物是 ARM 程序:
```bash
file "$(find build-arm-fb -type f -name IMX6U-Desktop | head -n 1)"
file "$(find build-arm-fb -type f -name IMX6U-Game | head -n 1)"
file "$(find build-arm-fb -type f -name IMX6U-LightGame | head -n 1)"
file "$(find build-arm-fb -type f -name IMX6U-Demo | head -n 1)"
```
正确结果应包含:
```text
ELF 32-bit LSB executable, ARM, EABI5
```
如果显示 `x86-64`,说明没有使用 ARM 工具链。
## Windows SCP 部署
将 WSL 编译出的四个程序复制到 Windows。以下示例假设文件位于:
```text
E:\嵌入式实验\IMX6U-Desktop
E:\嵌入式实验\IMX6U-Game
E:\嵌入式实验\IMX6U-LightGame
E:\嵌入式实验\IMX6U-Demo
```
板子地址示例:
```text
root@192.168.0.200
```
板端统一部署目录:
```text
/home/root/opt
```
先创建目录:
```powershell
ssh `
-o HostKeyAlgorithms=+ssh-rsa `
root@192.168.0.200 `
"mkdir -p /home/root/opt"
```
批量上传:
```powershell
$files = @(
"IMX6U-Desktop",
"IMX6U-Game",
"IMX6U-LightGame",
"IMX6U-Demo"
)
foreach ($file in $files) {
scp -O `
-o HostKeyAlgorithms=+ssh-rsa `
"E:\嵌入式实验\$file" `
"root@192.168.0.200:/home/root/opt/$file"
}
```
其中:
```text
-O 强制使用旧版 SCP 协议
HostKeyAlgorithms=+ssh-rsa 兼容板子上的旧 SSH 服务
```
ResBN 模型已经编译进 `IMX6U-Game`,不需要上传 `.tflite` 或其他模型文件。
## 板端运行
### 1. 登录板子
```powershell
ssh -o HostKeyAlgorithms=+ssh-rsa root@192.168.0.200
```
### 2. 初始化音频
```bash
cd /home/root/shell/audio
./mic_in_config.sh
amixer cset numid=41 1
```
`amixer cset numid=41 1` 会将 WM8960 的左 ADC 同时输出到左右声道。
板载 MIC 实测主要连接在 Left ADC,未设置时可能只有单声道有效。
### 3. 设置执行权限
```bash
cd /home/root/opt
chmod +x IMX6U-Desktop
chmod +x IMX6U-Game
chmod +x IMX6U-LightGame
chmod +x IMX6U-Demo
```
也可以统一执行:
```bash
chmod +x IMX6U-*
```
### 4. 启动 Desktop
```bash
cd /home/root/opt
./IMX6U-Desktop
```
Desktop 操作方式:
```text
点击屏幕左侧或右侧 切换游戏
水平滑动 切换游戏
点击中间游戏封面 启动当前游戏
```
Desktop 启动游戏前会释放 framebuffer 和触摸设备。游戏退出后,
Desktop 会重新初始化硬件并恢复主页。
Desktop 使用相对路径启动游戏,因此以下程序必须与 `IMX6U-Desktop`
处于同一目录:
```text
/home/root/opt/IMX6U-Desktop
/home/root/opt/IMX6U-Game
/home/root/opt/IMX6U-LightGame
/home/root/opt/IMX6U-Demo
```
### 5. 单独运行 TomGame
排查语音或音频问题时,可以绕过 Desktop 直接启动 TomGame:
```bash
cd /home/root/opt
./IMX6U-Game \
--fps 30 \
--kws-threshold 0.75 \
--kws-input-gain 1.0 \
--audio-input-device sysdefault:CARD=wm8960audio \
--audio-output-device sysdefault:CARD=wm8960audio
```
如果 `sysdefault:CARD=wm8960audio` 初始化失败,改用:
```bash
./IMX6U-Game \
--fps 30 \
--kws-threshold 0.75 \
--kws-input-gain 1.0 \
--audio-input-device plughw:0,0 \
--audio-output-device plughw:0,0
```
Desktop 启动 TomGame 时不会附加命令行参数,因此 TomGame 会使用 ALSA
的 `"default"` 输入和输出设备。如果直接运行 TomGame 正常,但从 Desktop
启动后音频初始化失败,需要将板子的 ALSA default 配置映射到 WM8960。
## 常见问题
### SSH 提示不支持 ssh-rsa
错误示例:
```text
Unable to negotiate ... no matching host key type found.
Their offer: ssh-rsa
```
连接时增加:
```text
-o HostKeyAlgorithms=+ssh-rsa
```
SCP 还建议增加 `-O`,以兼容板子上的旧版 SSH/Dropbear。
### 找不到 libasound
项目会优先使用:
```text
third_party/arm-linux-gnueabihf/alsa/lib/libasound.so
```
如果该文件不存在,需要安装兼容的 ARMHF ALSA 包,或者从板子复制匹配的
`libasound.so*` 到该目录。不要使用与板端系统版本不兼容的新版库。
### Desktop 可以启动,但点击游戏没有反应
检查四个程序是否位于同一个目录,并具有执行权限:
```bash
cd /home/root/opt
ls -l IMX6U-*
```
Desktop 的游戏目录配置为:
```text
TomGame -> IMX6U-Game
LightGame -> IMX6U-LightGame
Demo -> IMX6U-Demo
```
### TomGame 没有识别结果
按以下顺序检查:
1. 执行 `mic_in_config.sh` 和 `amixer cset numid=41 1`。
2. 使用 `arecord -l`、`arecord -L` 确认 WM8960 设备存在。
3. 使用 `arecord -vv` 检查说话时是否有明显音量变化。
4. 确认游戏已切换到关键词识别模式。
5. 检查日志中是否出现 `ResBN INT8 KWS result`。
6. 保持 `--kws-input-gain` 在 `(0, 1]` 范围。
7. 必要时将 `--kws-threshold` 从 `0.75` 临时降低到 `0.70` 进行链路测试。
Binary file not shown.
Binary file not shown.
@@ -1 +0,0 @@
-1
View File
@@ -1 +0,0 @@
-1
View File
@@ -1 +0,0 @@
-1
View File
@@ -1 +0,0 @@
-1
View File
@@ -1 +0,0 @@
Binary file not shown.

After

Width:  |  Height:  |  Size: 244 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 223 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 212 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 240 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 266 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 244 KiB

-1
View File
@@ -1 +0,0 @@
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

Some files were not shown because too many files have changed in this diff Show More