C++游戏开发之旅 1

这个游戏参考的是ziyu老师的教程,老师的教学很棒,非常适合新人学习,如果你也对游戏开发有兴趣,我很建议你去跟老师学习。这是老师的B站https://space.bilibili.com/3546810402474894
因为我也不是科班出身,基础有点弱,写这个的也是为了加强记忆和理解。其实ziyu老师也有一个web网站,里面的教程文档也是很详细的https://cppgamedev.top/
一起加油!

主要框架搭建

我们创建一个GameApp类来对游戏进行管理。这将是整个应用程序的管理者,封装所有SDL初始化、窗口管理和主循环相关逻辑。

game_app.h

#pragma once

struct SDL_Window;
struct SDL_Renderer; // 向前声明,加快编译速度

namespace engine::core{
/**
 *  @brief 主游戏应用程序类,初始化SDL,管理游戏循环
*/
class GameApp final {
private:
    SDL_Window* window_ = nullptr;
    SDL_Renderer* sdl_render_ = nullptr; 
    bool is_running_ = false;
public:
    GameApp();
    ~GameApp();
    /**
     * @breif 运行游戏时,先调用init()初始化,然后进入主循环,离开时自动调用close()
     */
    void run();

    // 拷贝和移动禁止
    GameApp(const GameApp&) = delete;
    GameApp& operator=(const GameApp&) = delete;
    GameApp(GameApp&&) = delete;
    GameApp& operator=(GameApp&&) = delete;

private:
    [[nodiscard]] bool init();
    void handleEvents();
    void update();
    void render();
    void close();
};
}

关键要点

  • 前向声明(Forward Declaration)-使用struct SDL_Window;来前向声明SDL的类型,而不是直接包含<SDL3/SDL.h>。这样做可以减少头文件的依赖,加快编译速度
  • 命名空间(Namespace)-所有引擎代码放在engine命名空间下,core是其子命名空间,有助于避免命名冲突
  • 禁止拷贝/移动-通过=delete,明确禁止了GameApp对象的拷贝和移动。程序中应该只存在一个GameApp实例
  • 声明周期方法-init,handleEvents,update,render,close这些私有方法定义游戏循环的各个阶段

game_app.cpp

#include "game_app.h"
#include <spdlog/spdlog.h>
#include <SDL3/SDL.h>


namespace engine::core
{
    GameApp::GameApp() = default;

    GameApp::~GameApp()
    {
        if(is_running_){
            spdlog::warn("GameApp销毁时没有显式关闭.现在关闭...");
            close();
        }
    }

    void GameApp::run()
    {
        if(!init()){
            spdlog::error("GameApp初始化失败,无法进行游戏");
            return;
        }
        while(is_running_){
            float delta_time = 0.016f;
            handleEvents();
            update(delta_time);
            render();
        }
        close();
    }

    bool GameApp::init()
    {
        // SDL初始化
        if(!SDL_Init(SDL_INIT_VIDEO | SDL_INIT_AUDIO)){
            spdlog::error("SDL初始化失败");
            return false;
        }
        window_ = SDL_CreateWindow("SunnyLand", 1280, 720, SDL_WINDOW_RESIZABLE); 
        if(!window_){
            spdlog::error("SDL窗口创建失败");
            return false;
        }
        sdl_renderer_ = SDL_CreateRenderer(window_, nullptr);
        if(!sdl_renderer_){
            spdlog::error("SDL渲染器创建失败");
            return false;
        }
        is_running_ = true;
        return true;
    }

    void GameApp::handleEvents()
    {
        SDL_Event event;
        while(SDL_PollEvent(&event)){
            switch(event.type){
                case SDL_EVENT_QUIT:
                    is_running_ = false;
                    break;
                default:
                    break;
            }
        }
    }

    void GameApp::update(float)
    {
    }

    void GameApp::render()
    {
        SDL_RenderClear(sdl_renderer_);
        SDL_RenderPresent(sdl_renderer_);
    }

    void GameApp::close()
    {
        if(sdl_renderer_){
            SDL_DestroyRenderer(sdl_renderer_);
            sdl_renderer_ = nullptr;
        }
        if(window_){
            SDL_DestroyWindow(window_);
            window_ = nullptr;
        }
        SDL_Quit();
        is_running_ = false;
    
    }

} // namespace engine::core

核心方法

  • 构造/析构函数-遵循RAII(Resource Acquisition Is Initialization)原则。构造函数什么都不做,但析构函数检查游戏是否运行,如果是,就调用close()确保资源正确释放,防止内存泄漏
  • run()方法-类的公共入口点。其首先调用init()进行初始化,如果成功,则进入while(is_running_)主循环。循环结束,自动调用close()进行清理
  • init()方法-负责所有初始化工作。初始化SDL的视频和音频子系统,创建窗口(SDL_CreateWindow)和渲染器(SDL_CreateRenderer)。每一步操作都需要进行错误检查,并通过spdlog输出错误信息
  • handleEvents()方法-使用SDL_PollEvent来轮询事件队列。目前,只处理SDL_EVENT_QUIT事件(例如点击关闭),接受事件后,is_running_设置为false,从而终止主循环
  • update()和render()方法- 目前是空的,主要对游戏逻辑进行更新和渲染
  • close()方法-负责释放所有在init()中申请的资源,顺序于申请时相反:先销毁渲染器,再销毁窗口,最后调用SDL_Quit()关闭整个SDL库

程序入口和编译配置

main.cpp 程序的入口很简单,唯一职责就是创建GameApp对象并调用run()方法

#include "engine/core/game_app.h"

int main(int, char**){
    engine::core::GameApp app;
    app.run();
    return 0;
}


CMakeLists.txt这个文件告诉CMake如何编译我们的项目。它设置了C++标准,找到了所有依赖库(SDL3,spdlog等),然后将我们的源文件编译成一个可执行文件,并把所有库链接进去。

cmake_minimum_required(VERSION 3.10.0)
project(Fox_20260104 VERSION 0.1.0 LANGUAGES C CXX) #项目名称

# 设置c++标准
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED True)

# 设置编译选项
if(MSVC)
    add_compile_options(/W4)
else()
    add_compile_options(-Wall -Wextra -Werror)
endif()

# 设置编译输出目录
set(CMAKE_RUNTIME_OUTPUT_DIRECTORY_DEBUG ${CMAKE_BINARY_DIR})
set(CMAKE_ARCHIVE_OUTPUT_DIRECTORY_RELEASE ${CMAKE_BINARY_DIR})

set(TARGET ${PROJECT_NAME}-${CMAKE_SYSTEM_NAME})

#查找并加载Cmake预设
find_package(SDL3 REQUIRED)
find_package(SDL3_image REQUIRED)
find_package(SDL3_mixer REQUIRED)
find_package(SDL3_ttf REQUIRED)
find_package(glm REQUIRED)
find_package(nlohmann_json REQUIRED)
find_package(spdlog REQUIRED)


# 添加可执行文件
add_executable(${TARGET}
                src/main.cpp
                src/engine/core/game_app.cpp
                )


# 链接库
target_link_libraries(${TARGET}
                        ${SDL3_LIBRARIES}
                        SDL3_image::SDL3_image
                        SDL3_mixer::SDL3_mixer
                        SDL3_ttf::SDL3_ttf
                        glm::glm
                        nlohmann_json::nlohmann_json
                        spdlog::spdlog
                        )

运行

现在配置好CMake并编译项目。出现窗口

image-20260104205709560

实现帧率控制

问题:前面我们定义了一个固定的时间,delta_time0.016f。我们希望不同电脑性能如何,都可以每帧花费固定的时间。但是却会导致不同电脑运行速度不同。

我们希望可以有一个与帧率无关的物理更新。要做到这一点,需要记录上一帧到当前帧所经过的时间,这个时间就是DeltaTime。

我们创建一个新的类Time。帧率稳定目标值60FPS。

Time类的实现

Time类负责所有与时间相关的计算。它会在每帧开始时被调用,计算出DeltaTime,并提供一个选项来将游戏帧率稳定在一个目标值

time.h

#pragma once
#include <SDL3/SDL_stdinc.h>

namespace engine::core
{
class Time final {
private:
    Uint64 last_time_ = 0; // 上一帧时间戳
    Uint64 frame_start_time_ = 0; // 当前帧开始时间戳
    double delta_time_ = 0; // 当前帧时间差
    double time_scale_ = 1.0; // 时间缩放因子
    
    
    // 帧率相关
    int target_fps_ = 0;
    double target_frame_time_ = 0.0;

public:
    Time();

    // 拷贝移动禁止
    Time(const Time&) = delete;
    Time& operator=(const Time&) = delete;
    Time(Time&&) = delete;
    Time& operator=(Time&&) = delete;

    /**
     * @brief 每帧开始时调用,更新内部时间状态并计算 DeltaTime
     */
    void update();

    /**
     * @brief 获取经时间缩放的当前帧的时间差
     * @return float 时间差
     */
    float getDeltaTime() const { return time_scale_ * delta_time_; }

    /**
     * @brief 获取未经时间缩放的当前帧的时间差
     * @return float 时间差
     */
    float getUnscaledDeltaTime() const { return delta_time_; }

    /**
     * @brief 设置时间缩放因子
     * @param scale 时间缩放因子, 默认为 1.0
     */
    void setTimeScale(float scale) { time_scale_ = scale; }

    /**
     * @brief 获取时间缩放因子
     */
    float getTimeScale() const { return time_scale_; }

    /**
     * @brief 设置目标帧率
     */
    void setTargetFPS(int fps) { target_fps_ = fps; target_frame_time_ = 1.0 / target_fps_; }

    /**
     * @brief 获取目标帧率
     */
    int getTargetFPS() const { return target_fps_; }

    private:
    /**
     * @brief 限制帧率, update中调用,用于限制帧率。如果设置target_fps_ > 0,则限制帧率,否则不限制
     * @param current_delta_time 当前帧时间差
     */
    void limitFrameRate(float current_delta_time);
    
};

} // namespace engine::core


  • 记录上一帧和当前帧的时间戳,计算delta_time_
  • 提供getDeltaTime()getUnscaledDeltaTime(),前者受time_scale_影响(实现慢动作/快进),后者则返回原始的帧耗时
  • 提供setTargetFps()设置目标帧率,如果为0,表示不设置

time.cpp

#include "time.h"
#include <spdlog/spdlog.h>
#include <SDL3/SDL_timer.h>

namespace engine::core{
    Time::Time()
    {
        spdlog::info("Time initialized with fps: {}", target_fps_);
        // 初始化last_time_ 和 frame_start_time_ 防止第一帧 Delta_time过大
        last_time_ = SDL_GetTicksNS();
        frame_start_time_ = last_time_;
    }

    void Time::update()
    {
        frame_start_time_ = SDL_GetTicksNS();
        double current_delta_time = (frame_start_time_ - last_time_) / 1e9;
        if(target_frame_time_ > 0.0){ // 若设置目标帧率,则限制帧率;否则delta_time_ = current_delta_time;
            limitFrameRate(current_delta_time);
        }else {
            delta_time_ = current_delta_time;
        }
        last_time_ = SDL_GetTicksNS();  // 记录离开 update 时的时间戳
    }
    void Time::limitFrameRate(float current_delta_time)
    {
        if(current_delta_time < target_frame_time_){
            double time_to_wait = target_frame_time_ - current_delta_time;
            Uint64 time_to_wait_ns = static_cast<Uint64>(time_to_wait * 1e9);
            SDL_DelayNS(time_to_wait_ns);
            delta_time_ = (SDL_GetTicksNS() - last_time_) / 1e9;
        } else {
            delta_time_ = target_frame_time_;
        }
    }
}

使用SDL_GetTicksNS(),返回SDL初始化以来的纳秒数(Uint64),这为我们提供了非常高的计时精度。

  • update()逻辑

    • 计算当前帧的时间戳和上一帧离开update()的时间戳的差值current_delta_time
    • 如果设置了帧率,就进行对比,如果current_delta_time < target_frame_time,进行delay
  • limitFrameRate()逻辑

    • 比较当前帧的耗时current_delta_time和目标耗时target_frame_time
    • 如果当前帧耗时更短,就用SDL_DelayNS()“暂停”程序,等待补足剩余时间
    • 等待结束,重新计算最终的delta_time_,此时会很接近target_frame_time
    • 如果耗时更长,就让delta_time=current_delta_time

集成Time到GameApp中

game_app.h修改

GameApp中添加一个std::unique_ptr<engine::core::Time> time_;成员。使用智能指针unique_ptr可以确保Time对象的生命周期由GameApp独占管理,并在GameApp销毁时自动释放内存。

  • GameApp的构造函数中,创建Time的实例:time_ = std::make_unique<Time>();
  • run()方法的主循环中,用以下代替
time_->update();
float delta_time = time_->getDeltaTime();

run()临时添加time_->setTargetFPS(60);来测试帧率限制

最后别忘记更新CMakeLists.txt

运行测试

image-20260105212017916

可以看到60帧时间差不多是1/60 = 0.01666s

目前我们完成了引擎的两个部分,主循环精确控制帧率

posted @ 2026-01-13 19:52  逆行的乌鸦  阅读(2)  评论(0)    收藏  举报