IMX6U-Game/docs/draft_LightGame.md

46 KiB
Raw Blame History

基于 IMX6U 与光敏传感器的 2D 平台跳跃游戏设计

学生姓名:黄俊 指导老师:⟨指导老师姓名⟩

摘 要 本实训针对无 GPU 或 GPU 能力极弱的嵌入式 SoCIMX6ULL/Cortex-A7上如何构建可玩性完整的图形化游戏这一问题设计并实现了一个纯 CPU 软光栅化 2D 渲染框架 Core以及一款以光敏传感器AP3216C作为核心输入的平台跳跃游戏 LightGame。渲染层采用统一 RGB565 帧缓冲、RGBA5551 精灵图集、1-bit 透明与整数定点亮度调制,将像素格式转换控制在显示后端边界,热路径全部使用整数运算与预分配缓冲;显示层抽象为 Platform::IDisplay 接口,分别在 Linux /dev/fb0 与 PC SDL2 后端上实现,通过 CMake 选项和条件编译一键切换。游戏层实现了亚像素积分物理、有限状态角色控制器、整数平滑跟随相机、房间网格分区与检查点重生等系统。传感器层绕开出厂 ap3216c 字符设备驱动,直接通过 /dev/i2c-0I2C_SLAVE_FORCE 抢占地址、将 AP3216C 重配置为 ALS-only 连续采样与最大增益模式,随后在应用层引入 Q8 定点一阶指数移动平均滤波,将 12 位光照值以约 0.3 Hz 截止频率平滑后驱动关卡对象通断、地形几何显隐与 HUD 反馈,形成"环境光即输入"的独特玩法。实验结果表明,在 IMX6U 板上以 1024×600、30 FPS 稳定运行,光照变化到游戏世界响应延迟约 0.7 s能够支撑完整的关卡切换与死亡重生流程验证了纯 CPU 渲染 + 环境传感器输入的技术路线在教学级嵌入式设备上的可行性。

关键词: IMX6ULL软光栅化RGB565AP3216CI²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 LevelEditorPC + 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 等具体平台差异,向上以 IDisplayITimeSourceIButtonInputIKeyboardStateIPointerInputIPhotoSensor 等纯虚接口提供显示、时钟、按键、键盘、指针和光敏传感器能力Core 层建立在 Platform 接口之上,包含 FrameBufferDepthBufferDrawContextRasterizerTilemapSpriteBitmapFontTimer 等与具体游戏无关的运行时组件,为上层应用提供统一的 2D 渲染与定时服务Apps 层位于最顶层,是各具体游戏的实现位置,本文的核心应用 LightGame 即位于 src/Apps/LightGame/,同项目中其他同学负责的 GameTom 游戏)与 Demo 也处于该层但彼此独立、不存在直接依赖。需要说明的是Shared 层按规划用于存放应用层共享的 UI、存档、配置等模块当前目录结构中尚未填充具体实现因此现有代码的实际依赖路径可视为 Apps -> Core -> Platform

单向依赖通过 CMake target 与 include 路径共同约束:Core -> AppsPlatform -> 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 抽象出 FBDisplaySDLDisplay 两套实现PC 与 ARM 共用同一份游戏和渲染代码。在输入抽象方面,按键、键盘、指针和光敏传感器分别对应 IButtonInputIKeyboardStateIPointerInputIPhotoSensor 接口,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 FBDisplayIMX6U 板端)

在 IMX6ULL 板端,FBDisplay 打开 /dev/fb0,通过 ioctl 读取 FBIOGET_FSCREENINFOFBIOGET_VSCREENINFO 获取像素格式和行步长,随后用 mmap 将整块显存映射到用户空间指针 fb_mempresent() 根据 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) 非阻塞检测 qQ 键,使主循环不会被输入阻塞。shutdownmunmap 前先用 memset 清屏,避免程序退出后屏幕残留最后一帧画面。

3.1.2 SDLDisplayPC 端)

PC 端 SDLDisplay 创建 SDL_WindowSDL_Renderer(SDL_RENDERER_ACCELERATED) 以及格式为 SDL_PIXELFORMAT_RGB565、访问模式为 STREAMINGSDL_Texture,与核心 FrameBuffer 的像素格式严格一一对应。present() 只需一次 SDL_UpdateTextureSDL_RenderCopySDL_RenderPresent。这种设计让 PC 侧无需为像素格式转换编写第二条路径,确保 PC 与 ARM 的可见画面完全等价。

3.1.3 时间源与输入

Platform::ITimeSource 独立于显示层提供单调递增的整数毫秒时钟Windows 实现基于 std::chrono::steady_clockLinux 实现基于 clock_gettime(CLOCK_MONOTONIC),返回值均为 uint32_t49 天回绕在一次游戏运行中可以忽略。按键、键盘与指针输入也采用同样模式——接口定义位于 Core/Platform/ARM 端由 Evdev 系列实现PC 端由 Sdl 系列实现,DefaultHardware.h 通过 typedef 决定调用点看到的具体类型。这种方式使得 LightGameApp 中不存在任何用于选择平台输入实现的 #ifdef TARGET_IMX

3.2 FrameBuffer 与统一像素格式

Core::FrameBuffer 在构造时一次性分配大小为 width * heightstd::vector<uint16_t> 作为 RGB565 像素缓冲,整个运行期间不再进行堆扩展。为了服务热路径绘制,它对外提供 set_pixel_unsafe(x, y, rgba32) 接口,调用方需自行保证坐标落在合法范围内,函数内部仅执行一次数组写入。颜色转换由头文件中的内联函数 rgba_to_frame_pixel 完成,其表达式仅包含 R 右移 3 位、G 右移 2 位、B 右移 3 位后的位或,编译器能够将这一短转换折叠进精灵和瓦片的内层循环。get_buffer() 返回缓冲区的原始指针,供 FBDisplaySDLDisplay 直接 memcpySDL_UpdateTexture 使用,避免额外的数据拷贝。

FrameBuffer 配套的还有 Core::DepthBuffer,它同样使用 uint16_t 存储每个像素的深度值,清空时写入 0xFFFF 表示最远,语义为“值越小越近”。该缓冲主要服务于三角形光栅化路径,虽然 LightGame 作为纯 2D 游戏并不使用其内容,但 DrawContext 仍统一持有它,以保持渲染 API 的完整性和未来向 3D 扩展的可能性。

3.3 DrawContext 绘制入口

Core::DrawContext 是整个渲染管线的门面对象,构造时创建 FrameBufferDepthBufferRasterizerTriangleRasterizer 各一份,禁止拷贝和移动,所有动态分配集中在构造与析构阶段完成,运行期不再出现 new/delete。游戏层通过它访问的接口包括清屏、绘制线段与三角形、精灵与瓦片绘制、填充矩形与位图文本以及最终的 present(IDisplay*) 帧提交。

在精灵绘制热路径上,源图集以 RGBA5551 格式存放,blit_sprite_pixels 在外层根据目标矩形完成一次屏幕裁剪,确定 start_dx / dyend_dx / dy 区间;内层则是一个紧凑的 for(sy) for(sx) 双循环,行指针 dst_row 在外层预先算出,水平/垂直翻转通过对读索引的三元表达式实现,编译器可将其优化为条件传送,避免在内层引入分支。此外,scale == 1scale > 1 两条路径分开实现前者内层不含除法。1-bit 透明通过 rgba5551_is_opaque 的位测试跳过,路径上没有任何 alpha 混合或乘法,最短流程每像素仅一次读取、一次测试和一次写入。

瓦片绘制同样遵循整数化原则。draw_tilemap_shadedshade_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::Tilemapuint16_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 带来的截断误差累积,它采用余数累积法计算每帧的固定时间片:

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_IMXTARGET_PC 宏创建对应的 Platform::IDisplay 实现,随后实例化 DefaultButtonInputDefaultKeyboardStateDefaultPointerInputDefaultPhotoSensor,这些具体类型由 DefaultHardware.h 按平台 typedef 决定。Core::DrawContextCore::Timer 随后以 1024×600 分辨率和 30 FPS 目标创建,LightGameApp 拿到输入、显示和时间源的指针后进入主循环。

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_UpdateTextureSDL_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 得到整像素位移并保留余数:

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% 截断

角色状态机在 IdleRunningJumpingFallingDead 五个状态之间转移,每帧根据 groundedmove_dirvelocity.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 像素的残余距离上无法追齐。

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_ 恢复对应格子;否则写入 EmptyTile0xFFFF。由于每次操作只涉及规则数量而非整张地图其复杂度为 O(rules)。

RoomLayout 则用整数网格把大关卡切分成若干矩形房间。RoomGrid 包含列数、行数、每个房间的像素宽高以及 RoomDef 数组,查询 room_index_of(grid, world_pos) 只需两次整除加上边界钳制即可得到当前房间索引。每个 RoomDef 携带 RoomBoundsdefault_spawn,切换房间时相机会立即更新边界,其它房间的检查点自动失效,从而形成类似 Metroidvania 的分区探索体验,同时避免了运行时加载关卡的额外开销。

4.6 LevelRenderer分层合成顺序

LevelRenderer 每帧按照从远到近的顺序合成画面。首先绘制可选的背景 tilemap调用 draw_tilemap_shaded 并指定 shade_numerator=80shade_shift=7,即以 80/128 的整数比例将背景压暗到约 0.625 倍亮度,全部通过定点乘法完成,不引入浮点。接着绘制前景 tilemap使用未经调暗的 draw_tilemap 快路径。然后遍历对象数组绘制非玩家对象,绘制前通过 LightEffectSystem::should_render 判断可见性:对于 LightPlatformShadowPlatformDoor,无论其当前 solid 状态如何都保持渲染,以便玩家读取光照规则;其它对象则以 obj.solid 作为是否绘制的依据。最后绘制玩家精灵,使其始终位于最上层。

IMX6U_DEBUG 调试构建中,LevelRenderer 还会追加一层 draw_debug,调用 Rasterizer::DrawLine 为每个活动对象绘制轴对齐碰撞盒,不同对象类型使用不同颜色。这也是当前项目中 Rasterizer::DrawLine 的主要使用场景。

4.7 GameStateManager 与 HUD

GameStateManager 管理游戏的高层状态机,包含 TitlePlayingPausedGameOverLevelComplete 五个状态。ESC 键通过 esc_was_down_ 进行边沿检测确保每次按键只触发一次状态切换Title 状态下开始提示以 500 ms 为周期闪烁,引导玩家按键进入游戏。

HUD 由 GameStateManagerLightGameApp 协作绘制。屏幕左上角有一条宽 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 的室内光分辨率。

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]=11full scale 323 lux最大增益
write(fd_, {0x10, 0x30}, 2);

之后,每帧 update() 执行两次“写寄存器地址再读一字节”的事务,分别读取 0x0CALS 低字节)和 0x0DALS 高字节),组合成 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滤波核心代码如下

// 状态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 时,玩家可以像穿过空气一样穿过该对象。视觉上,LightPlatformShadowPlatform 分别拥有 on/off 两组精灵,Door 拥有 closed/open 两组精灵,LevelRenderer 每帧根据当前 solid 状态切换贴图,实现“随光照凝固或消失”的效果。

这一设计的优势在于复用了 GameObject 已有的 solid 字段,物理层不需要为光敏对象编写特殊分支,代码路径保持统一;同时,关卡编辑器只需要为对象填写 min_levelmax_level 两个 12 位整数,即可表达“在多亮时能够踩上去”的规则,制作过程直观且不易出错。

5.5 光照 → 地形几何显隐

除了移动对象的实体状态,光照还可以改变地形几何本身。TileLightRuletile_xtile_ylight_minlight_max 四个字段组成,LightEffectSystem::update_tilemap(level, light) 逐条规则进行判断:若当前光照值落在规则窗口内,则将 tile_buffer_[y*w + x] 恢复为 original_tiles_[y*w + x] 中的真实 tile否则写入 Tilemap::EmptyTile0xFFFF使该格从前景中消失。

这里采用“原始 + 可变”双缓冲而不是原地翻转,是因为同一格子可能在不同光照窗口下多次进出,必须以 original_tiles_ 作为权威真相。这种设计让地图本身能够随光照消失或浮现,例如光下才显形的桥、暗中才崩塌的地板等效果,都可以通过规则配置实现。由于每次更新只涉及规则数量而非整张地图,其时间复杂度为 O(rules),与地图尺寸无关。

Tilemap::EmptyTile 同时被渲染层和物理层识别:渲染层遇到空瓦片跳过绘制,物理层的 is_solid_tile 对空瓦片返回 false。因此一次写入即同时完成“看不见”和“走得过去”两种语义无需在其它系统中进行额外同步。

5.6 光照 → HUD 视觉反馈与手动调试通道

光照不仅影响游戏逻辑也是玩家感知当前环境状态的第一视觉线索。HUD 左上角的光照指示条以颜色渐变实时展示当前 light 值,具体实现已在 §4.7 中说明。

在 PC 端开发时,由于没有真实的光敏传感器硬件,项目提供了手动调试通道:按下 WS 键可以以 ±256 的步长调节 manual_light_level_,并置位 has_manual_override_ 标志,使游戏绕过传感器读取而使用手动值。这样开发者可以在桌面环境中验证所有光敏关卡逻辑,这也是关卡编辑器在无硬件条件下的主要测试手段。

此外,IPhotoSensor 的 PC 实现 SdlPhotoSensor 默认返回 2048即 12 位量程的中点。该默认值保证桌面构建启动时处于“中等亮度”,避免开局黑屏或过度曝光,使调试体验与板端真实光照环境保持接近。

6. 跨平台构建与调试

6.1 CMake 选项与条件编译

顶层 CMakeLists.txt 通过两组开关驱动条件编译。第一组是 TARGET_IMX,默认关闭;开启时定义 TARGET_IMX 宏,将 FBDisplayAlsaAudioInputEvdevButtonInputAp3216cPhotoSensor 等板端实现编入核心库;关闭时则定义 TARGET_PC 宏,编入 SDLDisplaySdlAudioInputSdlKeyboardButtonInputSdlPhotoSensor 等 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 交叉编译构建中连相关符号都不会产生:

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 LevelEditorPC + Debug 独占的关卡编辑器

LevelEditor 是一个基于 ImGui 的关卡编辑器,直接在内存中编辑 Level 实例。它提供四类主要功能:工具切换,包括 TileBrushObjectPlaceSelectEraser;图层切换,可在 ForegroundBackground 之间选择;属性面板,用于修改选中对象的类型以及光照阈值 min_level / max_level;导出功能,通过 export_to_header(name) 将当前 tile 数组与对象列表按 LevelData 期望的 C 头文件结构序列化后写入磁盘。

由于 LevelData 是编译期常量,编辑器的“导出”实际上是“生成新头文件 → 重新编译”的循环。这一权衡的好处是运行时零 I/O、零解码代价是快速迭代时需要重新编译时间约为几十秒。

在主循环中,F1 键用于切换编辑器可见性。ImGui_ImplSDL2_ProcessEventLightGameApp 的输入并行处理当编辑器处于活动状态时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×60030 FPS 目标;
  • 度量方法:主循环中埋点 time_source.get_time_ms() 记录每帧的 update / draw / present / sleep 四段时长,采样若干秒的均值/最大值;
  • 预期结果:全场景稳定 30 FPSsleep 阶段应占多数(说明未跑满 CPU无掉帧、无撕裂。

7.2 传感器响应曲线测试

  • 度量方法:以已知光源分别做 (a) 覆盖—打开、(b) 缓慢遮挡两组动作,记录 rawsmoothed 曲线;
  • 预期结果:raw 抖动明显,smoothed 上升到 90% 的时间约 0.7 s无过冲、无稳态偏差。

7.3 关卡完整通关测试

  • 步骤:从 Title 开始,走过所有房间,触发若干 LightPlatform / ShadowPlatform / DoorTileLightRule,中途死亡 ≥ 1 次以验证检查点重生,最终触达 LevelComplete
  • 预期结果状态机无死锁房间切换无穿墙检查点在预期位置激活HUD 计数正确。

8. 工作总结与展望

本次实训完成了两块相互支撑的工作。其一是 Core 底层渲染库,它将“在 IMX6ULL 这类无 GPU 平台上,用 RGB565 帧缓冲和整数运算实现可玩 2D 画面”的实践,固化为 IDisplayDrawContextTilemapSpriteBitmapFontTimer 等可移植接口。其二是 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 相关引用⟩