diff --git a/README.md b/README.md index 91113cb..a77f172 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ - **Windows 编译**:开发主力机通常是 Windows,有 IDE 调试、有图形窗口,渲染算法对不对一眼就能看到。这个阶段完全不关心嵌入式细节。 - **Linux x86 编译**:验证代码在 GCC/Clang 下有无警告、CMake 配置是否跨平台、系统 SDL2 依赖是否正确。很多嵌入式工具链的问题在 x86 Linux 上就能提前暴露。 -- **ARM 交叉编译**:最终在 IMX6U 上跑。若目标板使用 SDL2,则 SDL2 仅作为显示/输入/计时适配层,核心渲染仍按 CPU framebuffer + 一次性提交设计;如需极简依赖,也保留 `/dev/fb0` 后端作为对照。 +- **ARM 交叉编译**:最终在 IMX6U 上跑。若目标板使用 SDL2,则 SDL2 仅作为显示/输入适配层,时间由独立 `Platform::ITimeSource` 提供,核心渲染仍按 CPU framebuffer + 一次性提交设计;如需极简依赖,也保留 `/dev/fb0` 后端作为对照。 ## 开发规范与性能红线 @@ -28,14 +28,14 @@ IMX6U 运行时性能预算较紧,后续开发必须遵守 `docs/DEVELOPMENT_G - 核心逻辑和热路径默认不新增 `float` / `double`,需要小数时使用项目统一定点数;只在显示、调试、导入导出等边界层转换成浮点。 - 主循环、每对象、每三角形/顶点/像素级代码中不得频繁创建 `std::vector` / `std::string` 等动态分配容器,应复用缓冲或使用固定容量结构。 - PC/SDL 版本用于调试验证,不能把调试便利写法扩散到 ARM release 核心路径。 -- SDL2 后端只做显示、输入、计时和最终 framebuffer 提交,不在核心渲染/游戏逻辑中直接调用 SDL API。 +- SDL2 后端只做显示、输入和最终 framebuffer 提交;时间源独立于显示后端,不在核心渲染/游戏逻辑中直接调用 SDL API。 - 新增核心代码前按规范文档中的检查清单自查。 ## 应用层与图形库拆分 项目后续按“两个游戏 + 一个启动器 + 一套底层图形库”组织,详细边界见 `docs/APP_AND_GFX_ARCHITECTURE.md`: -- `Gfx` / 底层图形库:只提供 framebuffer、SDL2/fb0 平台适配、输入/时间源、基础绘制能力,例如点、线、矩形、四边形、sprite、bitmap font、tilemap。 +- `Gfx` / 底层图形库:只提供 framebuffer、SDL2/fb0 平台适配、独立时间源、基础绘制能力,例如点、线、矩形、四边形、sprite、bitmap font、tilemap。 - `Apps/Launcher`:负责游戏选择、全局设置和启动流程。 - `Apps/GameA`、`Apps/GameB`:分别维护自己的游戏规则、状态、资源和渲染调用。 - `Shared`:只放应用层共享但不属于底层图形库的 UI、存档、配置、资源索引等。 @@ -66,6 +66,15 @@ cmake --build build-win --config Release ./build-win/Release/IMX6U-Game.exe ``` +可选帧率档位: +```bash +./build-win/Release/IMX6U-Game.exe --fps 30 +./build-win/Release/IMX6U-Game.exe --fps 45 +./build-win/Release/IMX6U-Game.exe --fps 60 +``` + +当前只接受 `30`、`45`、`60` 三档。主循环从 `Platform::ITimeSource` 读取单调整数毫秒时间,由 `Core::Timer` 生成固定步长 tick,并通过 `33/33/34`、`22/22/22/22/23`、`16/17/17` 这类整数节奏逼近对应目标帧率,避免核心时间源依赖 `float` 秒。 + ### Linux x86_64(Ubuntu / WSL2) 需要系统 SDL2: @@ -95,7 +104,7 @@ sudo apt-get install gcc-arm-linux-gnueabihf g++-arm-linux-gnueabihf 本项目 ARM 端保留两类后端: -- **SDL2 后端**:目标是后续游戏主路径;SDL2 负责显示、输入、计时和最终 framebuffer 提交。 +- **SDL2 后端**:目标是后续游戏主路径;SDL2 负责显示、输入和最终 framebuffer 提交,时间源使用独立的 `Platform::ITimeSource`。 - **Framebuffer 后端**:作为极简依赖和显示通路对照测试。 构建(Framebuffer 对照后端): @@ -196,7 +205,8 @@ assets/font/font_atlas.h │ Gfx:DrawContext、FrameBuffer、Draw2D、Math │ ├──────────────────────────────────────────────┤ │ Platform::IDisplay │ -│ - SDLDisplay : SDL2 显示/输入/计时适配 │ +│ - SDLDisplay : SDL2 显示/输入适配 │ +│ - ITimeSource: 单调整数毫秒时间源 │ │ - FBDisplay : /dev/fb0 对照后端 │ └──────────────────────────────────────────────┘ ``` @@ -228,7 +238,7 @@ IMX6U-Game/ │ │ ├─ RenderData/ # Color、Triangle 等数据结构 │ │ ├─ Scene/ # Camera、Transform、Mesh │ │ ├─ Shading/ # 着色器(预留) -│ │ ├─ Platform/ # IDisplay、SDLDisplay、FBDisplay +│ │ ├─ Platform/ # IDisplay、SDLDisplay、FBDisplay、ITimeSource │ │ └─ Asset/ # ObjLoader 等资源加载 │ ├─ Apps/ │ │ └─ Demo/ # 当前 3D 立方体 demo 入口 @@ -251,6 +261,7 @@ IMX6U-Game/ ### Core - **FrameBuffer**:CPU 侧颜色缓冲,渲染结果先写在这里 - **DepthBuffer**:深度测试用 Z-buffer +- **Timer**:整数毫秒固定步长 tick 生成器,支持 30/45/60 FPS 档位和每帧剩余时间计算 ### Math - 通用数学类型:`Vector2/3/4`、`Matrix4x4` @@ -264,6 +275,7 @@ IMX6U-Game/ - **IDisplay**:显示后端抽象,解耦渲染与输出 - **SDLDisplay**:SDL2 后端,PC 调试和 IMX6U SDL2 目标路径共用这一类适配思想 - **FBDisplay**:`/dev/fb0` 对照后端,用于极简显示通路验证 +- **ITimeSource / SteadyTimeSource**:独立时间源接口与单调时钟实现;Linux/IMX6U 使用 `clock_gettime(CLOCK_MONOTONIC)`,Windows 使用 `std::chrono::steady_clock`,Display 不再承担计时职责 ## 当前状态与后续 diff --git a/docs/APP_AND_GFX_ARCHITECTURE.md b/docs/APP_AND_GFX_ARCHITECTURE.md index 3c0c462..24f5976 100644 --- a/docs/APP_AND_GFX_ARCHITECTURE.md +++ b/docs/APP_AND_GFX_ARCHITECTURE.md @@ -17,7 +17,7 @@ IMX6U-Game/ │ │ ├─ RenderData/ # Color、Triangle 等数据结构(✅ 已实现) │ │ ├─ Scene/ # Camera、Transform(✅ 已实现) │ │ ├─ Shading/ # 着色器(预留) -│ │ ├─ Platform/ # SDL2 / fb0 平台适配(✅ 已实现) +│ │ ├─ Platform/ # SDL2 / fb0 显示适配与独立时间源(✅ 已实现) │ │ └─ Asset/ # 资源加载(✅ 已实现) │ ├─ Apps/ │ │ ├─ Demo/ # 当前 3D 立方体 demo(✅ 已实现) @@ -54,7 +54,7 @@ IMX6U-Game/ - 管理 framebuffer、depthbuffer、渲染上下文。 - 提供基础绘制接口:点、线、矩形、四边形、三角形、sprite、SpriteRegion、tilemap、简单文本等。 - 提供颜色、矩形、定点数、纹理、裁剪区域等基础数据结构。 -- 封装 SDL2 / framebuffer 显示提交、输入轮询、时间源。 +- 封装 SDL2 / framebuffer 显示提交、输入轮询,并通过独立 `ITimeSource` 提供单调整数毫秒时间。 - 使用离线转换后的紧凑资源数据(例如 PNG 转头文件、bitmap font atlas),运行时不直接依赖 PNG/TTF 解码。 - 只关心“怎么画得快、怎么提交到屏幕”,不关心具体游戏规则。 @@ -119,7 +119,7 @@ Shared 可以依赖 Gfx;Gfx 不能依赖 Shared。 ```text Apps/GameA ─┐ -Apps/GameB ─┼─> Shared ─> Gfx ─> Platform(SDL2/fb0) +Apps/GameB ─┼─> Shared ─> Gfx ─> Platform(SDL2/fb0/ITimeSource) Launcher ─┘ Apps/GameA ─┐ @@ -160,7 +160,7 @@ public: poll input -> update current app -> render current app -> present framebuffer ``` -这样三个应用层共用同一个主循环、同一套 SDL2 初始化和 framebuffer 提交流程。 +这样三个应用层共用同一个主循环、同一套 SDL2 初始化、framebuffer 提交流程和独立时间源。 注意:接口可以先保留虚函数,因为它只在每帧应用级调用,不在像素/顶点热路径中调用。像 `draw_rect`、`draw_quad`、`set_pixel_fast` 这类热路径函数不要虚化。 @@ -232,7 +232,7 @@ namespace Gfx ## 8. 性能注意事项 - 应用切换不应重复销毁/创建 SDL window、renderer、texture。 -- 三个应用共用 framebuffer、输入状态和时间源。 +- 三个应用共用 framebuffer、输入状态和 `Platform::ITimeSource` 时间源;Display 不承担计时职责。 - 每个应用可以有自己的资源缓存,但必须有上限和释放策略。 - Launcher 不应常驻消耗大量纹理/音频资源;进入游戏后可释放非必要启动器资源。 - Gfx 的绘制函数要保持小而直接,优先内联和连续内存写入。 diff --git a/docs/DEVELOPMENT_GUIDELINES.md b/docs/DEVELOPMENT_GUIDELINES.md index 3506faa..350f1ee 100644 --- a/docs/DEVELOPMENT_GUIDELINES.md +++ b/docs/DEVELOPMENT_GUIDELINES.md @@ -159,14 +159,14 @@ ## 12. IMX6U + SDL2 运行后端规范 -如果最终版本在 IMX6U 上使用 SDL2,而不是直接写 framebuffer,需要额外注意:SDL2 只是显示、输入、计时和窗口/屏幕适配层,不能把 SDL 渲染 API 当成主要性能来源。项目仍然应以 CPU 侧 framebuffer 为核心渲染结果,再以最少拷贝提交给 SDL2。 +如果最终版本在 IMX6U 上使用 SDL2,而不是直接写 framebuffer,需要额外注意:SDL2 只是显示、输入和窗口/屏幕适配层,不能把 SDL 渲染 API 当成主要性能来源;时间源应通过独立 `Platform::ITimeSource` 提供。项目仍然应以 CPU 侧 framebuffer 为核心渲染结果,再以最少拷贝提交给 SDL2。 ### 12.1 SDL2 边界 - SDL2 类型和调用只允许出现在 `src/Gfx/Platform/` 以及明确的平台适配层中。 - `src/Gfx/Core`、`src/Gfx/Math`、`src/Gfx/Rasterizer`、`src/Gfx/RenderData`、`src/Gfx/Scene`、`src/Gfx/Draw2D` 不应直接包含 `SDL.h`。 - 游戏逻辑不直接处理 `SDL_Event`,应转换为项目自己的输入状态结构。 -- 时间源可以来自 SDL,但核心逻辑使用整数 tick / fixed timestep,不直接依赖 float 秒数。 +- 时间源不挂在 `IDisplay` 上;核心逻辑从 `Platform::ITimeSource` 读取单调整数毫秒,并使用 `Core::Timer` 生成整数 tick / fixed timestep,不直接依赖 float 秒数。 ### 12.2 提交帧策略 @@ -188,7 +188,8 @@ - 先确定目标分辨率和目标 FPS,再决定渲染功能;不要默认使用屏幕原生高分辨率。 - IMX6U 上优先考虑低分辨率内部渲染,再由 SDL/显示层整数倍放大。 -- 目标建议至少维护两个档位:开发调试档、IMX6U 性能档。 +- 目标帧率当前维护 `30/45/60 FPS` 三档,通过命令行 `--fps 30|45|60` 选择;IMX6U 默认优先 30 FPS,45/60 FPS 需要以实测帧时间确认。 +- 固定步长 tick 使用整数毫秒余数累计,例如 30 FPS 为 `33/33/34 ms` 节奏,45 FPS 为 `22/22/22/22/23 ms` 节奏,60 FPS 为 `16/17/17 ms` 节奏。 - 性能档应限制:最大三角形数、最大 sprite 数、最大粒子数、最大动态光源数、最大纹理尺寸。 - 所有预算都应以实测帧时间为准,不能只按 PC 表现推断。 diff --git a/src/Apps/Demo/main.cpp b/src/Apps/Demo/main.cpp index e251a37..faaea1b 100644 --- a/src/Apps/Demo/main.cpp +++ b/src/Apps/Demo/main.cpp @@ -2,6 +2,9 @@ #include #include #include +#include +#include +#include #include "Vector2.h" #include "Vector3.h" #include "Vector4.h" @@ -11,12 +14,14 @@ #include "Triangle.h" #include "Camera.h" #include +#include "Timer.h" #include "Vertex.h" #include "DrawContext.h" #include "test_sprite.h" #include "font_atlas.h" #include "Display.h" +#include "TimeSource.h" #ifdef USE_FRAMEBUFFER #include "FBDisplay.h" #else @@ -103,8 +108,93 @@ static bool IsTriangleVisible(const CubeTriangle &triangle, const std::array 0.0f; } +static void PrintUsage(const char *program_name) +{ + std::cout + << "Usage: " << program_name << " [--fps 30|45|60]\n" + << " " << program_name << " [--fps=30|45|60]\n"; +} + +struct ProgramOptions +{ + uint32_t target_fps; + bool show_help; + + ProgramOptions() + : target_fps(Core::Timer::DefaultFps), + show_help(false) + { + } +}; + +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; + + if (std::strcmp(arg, "--help") == 0 || std::strcmp(arg, "-h") == 0) + { + options.show_help = true; + return options; + } + else if (std::strcmp(arg, "--fps") == 0) + { + if (i + 1 < argc) + { + fps_value = argv[++i]; + } + else + { + std::cerr << "Missing value for --fps, falling back to 30. Supported: 30, 45, 60.\n"; + } + } + else if (std::strncmp(arg, "--fps=", 6) == 0) + { + fps_value = arg + 6; + } + + if (fps_value != nullptr) + { + const uint32_t parsed_fps = static_cast(std::strtoul(fps_value, nullptr, 10)); + if (Core::Timer::is_supported_fps(parsed_fps)) + { + options.target_fps = parsed_fps; + } + else + { + std::cerr << "Unsupported FPS '" << fps_value << "', falling back to 30. Supported: 30, 45, 60.\n"; + options.target_fps = Core::Timer::DefaultFps; + } + } + } + + return options; +} + +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) + { + std::this_thread::sleep_for(std::chrono::milliseconds(sleep_ms)); + } +} + int main(int argc, char *argv[]) { + const ProgramOptions options = ParseProgramOptions(argc, argv); + if (options.show_help) + { + PrintUsage(argv[0]); + return 0; + } + Core::Timer timer(options.target_fps); + Platform::SteadyTimeSource time_source; + #ifdef USE_FRAMEBUFFER Platform::IDisplay *display = new Platform::FBDisplay(); #else @@ -118,6 +208,7 @@ int main(int argc, char *argv[]) } Gfx::DrawContext ctx(width, height); + std::cout << "Target FPS: " << timer.target_fps() << std::endl; RenderData::BitmapFont font; font.atlas = RenderData::Image(font_atlas_pixels, font_atlas_width, font_atlas_height); @@ -177,16 +268,21 @@ int main(int argc, char *argv[]) char fps_text[32]; bool isRunning = true; + uint32_t animation_time_ms = 0; while (isRunning) { + timer.begin_frame(time_source.get_time_ms()); + const uint32_t fixed_delta_ms = timer.fixed_delta_ms(); + animation_time_ms += fixed_delta_ms; + display->poll_events(isRunning); ctx.clear(clearColor); - const float timeSeconds = static_cast(display->get_time_ms()) * 0.001f; + const float animation_time = static_cast(animation_time_ms) / 1000.0f; const Math::Matrix4x4 model = - Math::MathUtil::get_rotation_matrix_y(timeSeconds) * - Math::MathUtil::get_rotation_matrix_x(timeSeconds * 0.6f); + Math::MathUtil::get_rotation_matrix_y(animation_time) * + Math::MathUtil::get_rotation_matrix_x(static_cast(animation_time_ms * 6u) / 10000.0f); const Math::Matrix4x4 view = camera.get_view_matrix(); const Math::Matrix4x4 modelView = view * model; const Math::Matrix4x4 projection = camera.get_perspective_projection_matrix(aspectRatio); @@ -266,11 +362,11 @@ int main(int argc, char *argv[]) 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_tilemap(testTilemap, 650, 500, 96, 48, static_cast(display->get_time_ms() / 20) % 32, 0); + ctx.draw_tilemap(testTilemap, 650, 500, 96, 48, static_cast(animation_time_ms / 20u) % 32, 0); // FPS 计数 ++frame_count; - const uint32_t now = display->get_time_ms(); + const uint32_t now = time_source.get_time_ms(); if (now - last_fps_time >= 1000) { fps = frame_count; @@ -281,6 +377,7 @@ int main(int argc, char *argv[]) ctx.draw_text(font, 4, 4, fpsColor, fpsBg, fps_text); ctx.present(display); + SleepRemainingFrameTime(timer, time_source); } display->shutdown(); diff --git a/src/Gfx/Core/Timer.h b/src/Gfx/Core/Timer.h new file mode 100644 index 0000000..cf98c52 --- /dev/null +++ b/src/Gfx/Core/Timer.h @@ -0,0 +1,71 @@ +#pragma once + +#include + +namespace Core +{ + class Timer + { + public: + static const uint32_t DefaultFps = 30; + + explicit Timer(uint32_t target_fps = DefaultFps) + : target_fps_(normalize_fps(target_fps)), + tick_remainder_(0), + frame_start_ms_(0), + fixed_delta_ms_(0) + { + } + + void begin_frame(uint32_t now_ms) + { + frame_start_ms_ = now_ms; + fixed_delta_ms_ = next_tick_ms(); + } + + uint32_t target_fps() const + { + return target_fps_; + } + + uint32_t fixed_delta_ms() const + { + return fixed_delta_ms_; + } + + uint32_t frame_start_ms() const + { + return frame_start_ms_; + } + + uint32_t remaining_frame_ms(uint32_t now_ms) const + { + const uint32_t elapsed_ms = now_ms - frame_start_ms_; + return elapsed_ms < fixed_delta_ms_ ? fixed_delta_ms_ - elapsed_ms : 0u; + } + + static bool is_supported_fps(uint32_t fps) + { + return fps == 30u || fps == 45u || fps == 60u; + } + + static uint32_t normalize_fps(uint32_t fps) + { + return is_supported_fps(fps) ? fps : DefaultFps; + } + + private: + uint32_t next_tick_ms() + { + tick_remainder_ += 1000u; + const uint32_t tick_ms = tick_remainder_ / target_fps_; + tick_remainder_ %= target_fps_; + return tick_ms; + } + + uint32_t target_fps_; + uint32_t tick_remainder_; + uint32_t frame_start_ms_; + uint32_t fixed_delta_ms_; + }; +} diff --git a/src/Gfx/Platform/Display.h b/src/Gfx/Platform/Display.h index f5668a5..c6a03a2 100644 --- a/src/Gfx/Platform/Display.h +++ b/src/Gfx/Platform/Display.h @@ -15,7 +15,6 @@ namespace Platform virtual bool init(int width, int height) = 0; virtual void present(const Core::FrameBuffer* framebuffer) = 0; virtual void poll_events(bool& should_quit) = 0; - virtual uint32_t get_time_ms() const = 0; virtual void shutdown() = 0; }; } diff --git a/src/Gfx/Platform/FBDisplay.cpp b/src/Gfx/Platform/FBDisplay.cpp index 8c9ff13..fd22d48 100644 --- a/src/Gfx/Platform/FBDisplay.cpp +++ b/src/Gfx/Platform/FBDisplay.cpp @@ -119,13 +119,6 @@ namespace Platform } } - uint32_t FBDisplay::get_time_ms() const - { - struct timespec ts; - clock_gettime(CLOCK_MONOTONIC, &ts); - return static_cast(ts.tv_sec * 1000 + ts.tv_nsec / 1000000); - } - void FBDisplay::shutdown() { if (fb_mem != nullptr) diff --git a/src/Gfx/Platform/FBDisplay.h b/src/Gfx/Platform/FBDisplay.h index 45ecb4d..a76ca00 100644 --- a/src/Gfx/Platform/FBDisplay.h +++ b/src/Gfx/Platform/FBDisplay.h @@ -21,7 +21,6 @@ namespace Platform bool init(int w, int h) override; void present(const Core::FrameBuffer* framebuffer) override; void poll_events(bool& should_quit) override; - uint32_t get_time_ms() const override; void shutdown() override; }; } diff --git a/src/Gfx/Platform/SDLDisplay.cpp b/src/Gfx/Platform/SDLDisplay.cpp index 4c2bff5..88bac6d 100644 --- a/src/Gfx/Platform/SDLDisplay.cpp +++ b/src/Gfx/Platform/SDLDisplay.cpp @@ -76,11 +76,6 @@ namespace Platform } } - uint32_t SDLDisplay::get_time_ms() const - { - return SDL_GetTicks(); - } - void SDLDisplay::shutdown() { if (texture != nullptr) diff --git a/src/Gfx/Platform/SDLDisplay.h b/src/Gfx/Platform/SDLDisplay.h index 30e227c..0c0278b 100644 --- a/src/Gfx/Platform/SDLDisplay.h +++ b/src/Gfx/Platform/SDLDisplay.h @@ -17,7 +17,6 @@ namespace Platform bool init(int w, int h) override; void present(const Core::FrameBuffer* framebuffer) override; void poll_events(bool& should_quit) override; - uint32_t get_time_ms() const override; void shutdown() override; }; } diff --git a/src/Gfx/Platform/TimeSource.h b/src/Gfx/Platform/TimeSource.h new file mode 100644 index 0000000..ad838ca --- /dev/null +++ b/src/Gfx/Platform/TimeSource.h @@ -0,0 +1,59 @@ +#pragma once + +#include + +#ifdef _WIN32 +#include +#else +#include +#endif + +namespace Platform +{ + class ITimeSource + { + public: + virtual ~ITimeSource() {} + virtual uint32_t get_time_ms() const = 0; + }; + + class SteadyTimeSource : public ITimeSource + { + public: + SteadyTimeSource() + : +#ifdef _WIN32 + start_(std::chrono::steady_clock::now()) +#else + start_ms_(read_monotonic_ms()) +#endif + { + } + + uint32_t get_time_ms() const override + { +#ifdef _WIN32 + const std::chrono::steady_clock::time_point now = std::chrono::steady_clock::now(); + const std::chrono::milliseconds elapsed = + std::chrono::duration_cast(now - start_); + return static_cast(elapsed.count()); +#else + return read_monotonic_ms() - start_ms_; +#endif + } + + private: +#ifdef _WIN32 + std::chrono::steady_clock::time_point start_; +#else + static uint32_t read_monotonic_ms() + { + struct timespec ts; + clock_gettime(CLOCK_MONOTONIC, &ts); + return static_cast(ts.tv_sec * 1000u + ts.tv_nsec / 1000000u); + } + + uint32_t start_ms_; +#endif + }; +}