C++游戏开发之旅 3

概述

之前完成了主循环、帧率控制以及资源管理模块,现在我们需要完成渲染工作,将游戏对象绘制到屏幕上。

这章需要理解

  • 精灵(Sprite)-描述“画什么”,使用哪张纹理、纹理的哪一部分
  • 相机(Camera)-玩家视窗,决定显示,负责坐标和视图转换
  • 渲染器(Renderer)-封装底层绘制API,接收精灵与相机信息,执行真正的绘制精灵

image-20260113201508810


1.逻辑分辨率:像素游戏的关键

像素风格的游戏,希望在不同物理分辨率(1080p/2k/4k)下,画面像素块保持同样观感,避免被拉伸变形。SDL3提供了逻辑分辨率方法

实现--->在GameApp::initSDL()中添加

SDL_SetRenderLogicalPresentation(sdl_renderer_,640,360,SDL_LOGICAL_PRESENTATION_LETTERBOX); // 设置渲染器逻辑分辨率

设置含义:

  • 逻辑画布固定 - 为640×360
  • 自动缩放 - SDL会将逻辑画布等比例缩放以适配实际窗口大小
  • 黑边填充 - 当窗口宽高比不为16:9时,LEFTRBOX模式自动填充黑边,避免画面拉伸

所有游戏逻辑与坐标计算均在640×360的逻辑分辨率下进行,大幅简化开发与适配难度。

Q:什么是逻辑画布

A:就用640×360(16:9)举例子,我们在这个虚拟尺寸上做游戏,比如我在(100,50)这个坐标放了一个大小(40,40)的方块也就是说在这张640×360画纸上,画在了第100列、50行的位置。但实际上我们的窗口是1280×720(16:9),窗口比例和逻辑画布是一样的,那么就会显示缩放倍数为2的样子,没有黑边,真实像素位置:(100*2,50*2)=(200,100),真实像素大小:(40*2,40*2)=(80,80),代码里写的是(100,50,40,40)但玩家用户见到的显示确是(200,100,80,80)

但是如果不是16:9的窗口那怎么办?比如800×600(4:3),我们用了LETTERBOX,所以必须保持画纸的16:9,放不下的地方就加黑边了。

先算缩放倍数:

  • 800/640 = 1.25
  • 600/360 = 1.666…
    为了“完整塞进窗口”,得取更小的那个 → scale = 1.25

缩放后画面尺寸:

  • 宽:640*1.25 = 800
  • 高:360*1.25 = 450

窗口是 800×600,但内容只有 800×450,所以还剩下:

  • 垂直方向多出来 600-450 = 150
    上下各一半 → 上黑边 75 px,下黑边 75 px

这时方块在真实窗口里的位置变成:

  • 真实 x = 100*1.25 = 125
  • 真实 y = 50*1.25 + 75 = 62.5 + 75 = 137.5(大概 138)
  • 真实 w = 40*1.25 = 50
  • 真实 h = 40*1.25 = 50

重点:你还是画在逻辑坐标 (100,50),SDL 帮你加了“缩放 + 黑边偏移”。

2.新项目结构

我们将渲染相关类放入render/目录,将通用工具放入utils/目录,需要在render目录下创建renderer.h/cpp、camera.h/cpp、sprite.hutils下创建math.h

3.Sprite类

Sprite是一个轻量级数据类,用来描述一个“待绘制”的视觉元素,不包含复杂逻辑

// sprite.h
#pragma once
#include <SDL3/SDL_rect.h> // SDL_FRect
#include <optional>     // std::optional 表示可选源矩形
#include <string>

namespace engine::render{
/**
 * @brief 表示绘制精灵的数据
 * 包含纹理资源标识符、源矩形和是否水平翻转
 * 位置、缩放和旋转由外部(例如 SpriteComponent) 标识
 * 渲染工作由 Renderer类完成。(传入Sprite作为参数)
 */
class Sprite final {
private:
    std::string texture_id_; // 纹理资源标识符
    std::optional<SDL_FRect> source_rect_; // 要绘制的纹理部分
    bool is_flipped_ = false; // 是否水平翻转
public:
    /**
     * @brief 构造一个精灵
     * @param texture_id 纹理资源标识符
     * @param source_rect 要绘制的纹理部分 这是一个可选的参数,定义要使用的纹理部分,若为std::nullopt,则使用整个纹理
     * @param is_flipped 是否水平翻转
     */
    Sprite(const std::string& texture_id, const std::optional<SDL_FRect>& source_rect = std::nullopt, bool is_flipped = false)
    : texture_id_(texture_id), 
      source_rect_(source_rect), 
      is_flipped_(is_flipped) 
    {}

    // --- getters and setters ---
    const std::string& getTextureId() const { return texture_id_; }
    const std::optional<SDL_FRect>& getSourceRect() const { return source_rect_; }
    bool isFlipped() const { return is_flipped_; }

    void setTextureId(const std::string& texture_id) { texture_id_ = texture_id; }
    void setSourceRect(const std::optional<SDL_FRect>& source_rect) { source_rect_ = source_rect; }
    void setFlipped(bool is_flipped) { is_flipped_ = is_flipped; }

};
}
核心属性
  • texture_id_ : 要使用的纹理标识
  • source_rect_ : 可选的SDL_FRect,用于从原图中截取子区域,比如一张图上有多个游戏对象,就需要截取子区域来获取,如果是nullopt就使用整张纹理
  • is_flipped_ : 是否水平翻转

4.Camera类

Camera模拟摄像机,定义在广阔世界中可见的区域,核心职责是坐标变换和视图控制

// camera.h
#pragma once
#include "../utils/math.h"
#include <optional>

namespace engine::render {

class Camera final {
private:
    glm::vec2 viewport_size_; // 视口大小(屏幕大小)
    glm::vec2 position_;    // 相机左上角的世界坐标
    std::optional<engine::utils::Rect> limit_bounds_; // 相机限制范围,为空则无限制

public:
    Camera(const glm::vec2& viewport_size, const glm::vec2& position = glm::vec2(0.0f, 0.0f),const std::optional<engine::utils::Rect> limit_bounds = std::nullopt);

    void update(float delta_time);      // 更新相机位置
    void move(const glm::vec2& offset); // 移动相机
    
    glm::vec2 worldToScreen(const glm::vec2& world_pos) const; // 将世界坐标转换为屏幕坐标
    glm::vec2 screenToWorld(const glm::vec2& screen_pos) const; // 将屏幕坐标转换为世界坐标
    // 将世界坐标转换为屏幕坐标,考虑视差效果
    glm::vec2 worldToScreenWithParallax(const glm::vec2& world_pos, const glm::vec2& scroll_factor) const; 

    void setPosition(const glm::vec2& position); // 设置相机位置
    void setLimitBounds(const engine::utils::Rect& bounds); // 设置相机限制范围

    const glm::vec2& getPosition() const; // 获取相机位置
    std::optional<engine::utils::Rect> getLimitBounds() const; // 获取相机限制范围
    const glm::vec2& getViewportSize() const; // 获取视口大小

    // 禁用拷贝和移动语义
    Camera(const Camera&) = delete;
    Camera& operator=(const Camera&) = delete;
    Camera(Camera&&) = delete;
    Camera& operator=(Camera&&) = delete;
    
private:
    void clampPosition(); // 限制相机位置
};
    
}

// camera.cpp
#include "camera.h"
#include <spdlog/spdlog.h>

namespace engine::render {
    Camera::Camera(const glm::vec2 &viewport_size, const glm::vec2 &position, const std::optional<engine::utils::Rect> limit_bounds)
    : viewport_size_(viewport_size), position_(position), limit_bounds_(limit_bounds)
    {
        spdlog::trace("Camera初始化成功,位置:{},{}", position.x, position.y);
    }

    void Camera::update(float )
    {

    }

    void Camera::move(const glm::vec2 &offset)
    {
        position_ += offset;
        clampPosition();
    }

    glm::vec2 Camera::worldToScreen(const glm::vec2 &world_pos) const
    {
        // 屏幕坐标 = 世界坐标 - 相机位置 * 1
        return world_pos - position_;
    }

    glm::vec2 Camera::screenToWorld(const glm::vec2 &screen_pos) const
    {
        // 世界坐标 = 屏幕坐标 + 相机位置
        return screen_pos + position_;
    }

    glm::vec2 Camera::worldToScreenWithParallax(const glm::vec2 &world_pos, const glm::vec2 &scroll_factor) const
    {
        // 屏幕坐标 = 世界坐标 - 相机位置 * 滚动因子
        return world_pos - position_ * scroll_factor;
    }

    void Camera::setPosition(const glm::vec2 &position)
    {
        position_ = position;
        clampPosition();
    }

    void Camera::setLimitBounds(const engine::utils::Rect &bounds)
    {
        limit_bounds_ = bounds;
        clampPosition(); // 设置限制边界后,立刻应用限制
    }

    const glm::vec2 &Camera::getPosition() const
    {
        return position_;
    }

    std::optional<engine::utils::Rect> Camera::getLimitBounds() const
    {
        return limit_bounds_;
    }

    const glm::vec2 &Camera::getViewportSize() const
    {
        return viewport_size_;
    }

    void Camera::clampPosition()
    {
        if(limit_bounds_.has_value() && limit_bounds_->size.x > 0 && limit_bounds_->size.y > 0){
            // 计算允许的相机位置范围
            glm::vec2 min_pos = limit_bounds_->position;
            glm::vec2 max_pos = limit_bounds_->position + limit_bounds_->size - viewport_size_;
            
            // 如果世界大小小于视口大小呢? 这样min_pos 就比 max_pos 大了,max_pos < (0,0)
            // 我要保证max_pos 一定是大的
            max_pos.x = std::max(min_pos.x, max_pos.x);
            max_pos.y = std::max(min_pos.y, max_pos.y);

            position_ = glm::clamp(position_, min_pos, max_pos);

        }
    }
}
坐标系统
  • 世界坐标&屏幕坐标 - 在"世界坐标"(关卡中的绝对位置)与"屏幕坐标"(640×360画布上的绘制位置)之间转换
核心方法
  • worldToScreen() - 将世界坐标转换为屏幕坐标,核心为screen_pos = world_pos - camera_pos
视差滚动(Parallax)

worldToScreenWithParallax(scroll_factor)按滚动因子进行相对运动:

  • scroll_factor = 1 -> 与相机同步移动
  • scroll_factor = 0 -> 禁止(经典UI)
  • 0 < scroll_factor < 1 -> 产生景深效果的视差滚动

用眼睛来作为相机,当我们往左边看去,近处的物体是向右边移动的,我相机往左移动5个单位,可以近似看作近处物体往右边移动5个单位,但是远处的景观不同,可能只移动3个单位,更远处的可能只有1个单位。我们可以利用这个视差滚动来实现游戏对象的显示更贴近实际

移动边界

setLimitBounds()限制相机移动范围,防止越界关卡。很简单,我们希望Camera位置在限定的位置移动。

image-20260117150333210

比如我们希望Camera的pos只在世界坐标的范围中,那么我的camera_pos = world_pos - camera_viewport_sizecamera_pos(camera左上角)只允许在蓝色框中。

自定义Rect

相机中的limitBounds会用到engine::utils::Rect定义在src/engine/utils/math.h中:

// math.h
#pragma once
#include<glm/glm.hpp>

namespace engine::utils {
struct Rect{
    glm::vec2 position;
    glm::vec2 size;
};
    
}

5.Renderer类

Renderer是渲染中枢,封装SDL底层绘制API,并提供更高层、易用的绘制接口:

// renderer.h
#pragma once
#include "sprite.h"
#include <glm/glm.hpp>

struct SDL_Renderer;

namespace engine::resource {
    class ResourceManager;
}

namespace engine::render {
class Camera;

/**
 * @brief 封装SDL3的渲染操作
 * 包装 SDL_Renderer 并提供清除屏幕、绘制精灵和呈现最终图像的方法
 * 构造时初始化。依赖于一个有效的 SDL_Renderer 和 ResourceManager
 * 构造失败抛出异常
 */
class Renderer final {
private:
    SDL_Renderer *sdl_renderer_ = nullptr; // SDL3的渲染器
    engine::resource::ResourceManager *resource_manager_ = nullptr; // 资源管理器

public:
    /**
     * @brief 构造函数
     * @param sdl_renderer SDL3的渲染器
     * @param resource_manager 资源管理器
     * @throws std::runtime_error 如果 sdl_renderer 或 resource_manager 为空 -> 抛出异常
     */
    Renderer(SDL_Renderer *sdl_renderer, engine::resource::ResourceManager *resource_manager);

    /**
     * @brief 绘制一个精灵
     * 
     * @param camera 摄像机
     * @param sprite 精灵 包含 纹理ID、源矩形、是否翻转
     * @param position 精灵位置
     * @param scale 缩放因子
     * @param angle 旋转角度
     */
    void drawSprite(const Camera& camera, const Sprite& sprite, const glm::vec2& position,
                    const glm::vec2& scale = {1.0f,1.0f}, double angle = 0.0f);

    /**
     * @brief 绘制视差滚动背景
     * 
     * @param camera 摄像机
     * @param sprite 精灵 包含 纹理ID、源矩形、是否翻转
     * @param position 精灵位置
     * @param scroll_factor 滚动因子
     * @param repeat 是否重复
     * @param scale 缩放因子
     */

    void drawParallax(const Camera& camera, const Sprite& sprite, const glm::vec2& position,
                      const glm::vec2& scroll_factor, const glm::bvec2& repeat = {true, true}, 
                      const glm::vec2& scale = {1.0f,1.0f});
    
    /**
     * @brief 屏幕中直接绘制一个UI精灵对象
     * 
     * @param sprite 精灵 包含 纹理ID、源矩形、是否翻转
     * @param position 精灵位置
     * @param size (std::optional)目标矩形大小。如果为nullopt,则使用精灵的默认大小
     * 
     */
    
    void drawUISprite(const Sprite& sprite, const glm::vec2& position,
                      const std::optional<glm::vec2> size = std::nullopt);

    
    void present(); // 更新屏幕 --- SDL_RenderPresent 函数
    
    void clearScreen(); // 清屏 --- SDL_RenderClear 函数

    void setDrawColor(Uint8 r, Uint8 g, Uint8 b, Uint8 a = 255); // 设置绘制颜色
    void setDrawColorFloat(float r, float g, float b, float a = 1.0f); // 设置绘制颜色

    SDL_Renderer* getSDLRenderer() const {return sdl_renderer_;}; // 获取SDL3的渲染器
    
    // 禁用拷贝和移动语义
    Renderer(const Renderer&) = delete;
    Renderer& operator=(const Renderer&) = delete;
    Renderer(Renderer&&) = delete;
    Renderer& operator=(Renderer&&) = delete;
    
private:
    std::optional<SDL_FRect> getSpriteSrcRect(const Sprite& sprite) const; // 获取精灵的源矩形
    bool isRectInViewport(const Camera& camera, const SDL_FRect& rect) const; // 判断矩形是否在视口中


};

}
// renderer.cpp
#include "renderer.h"
#include "../resource/resource_manager.h"
#include "camera.h"
#include <SDL3/SDL.h>
#include <stdexcept>
#include <spdlog/spdlog.h>

namespace engine::render {
    Renderer::Renderer(SDL_Renderer *sdl_renderer, engine::resource::ResourceManager *resource_manager)
    : sdl_renderer_(sdl_renderer), resource_manager_(resource_manager) 
    {
        if(!sdl_renderer_ || !resource_manager_){
            throw std::runtime_error("sdl_renderer or resource_manager is null");
        }
        setDrawColor(0,0,0,255);
        spdlog::trace("渲染器创建成功!");
    }

    void Renderer::drawSprite(const Camera &camera, const Sprite &sprite, const glm::vec2 &position, const glm::vec2 &scale, double angle)
    {
        auto texture = resource_manager_->getTexture(sprite.getTextureId());
        if(!texture){
            spdlog::error("无法为ID {} 获得纹理。", sprite.getTextureId());
            return;
        }
        auto src_rect = getSpriteSrcRect(sprite);
        if(!src_rect){
            spdlog::error("无法为ID {} 获得源矩形。", sprite.getTextureId());
            return;
        }

        // 相机变换
        glm::vec2 position_screen = camera.worldToScreen(position);

        // 计算目标矩形, pos是左上角坐标
        SDL_FRect dst_rect = {
            position_screen.x,
            position_screen.y,
            src_rect.value().w * scale.x,
            src_rect.value().h * scale.y
        };
        
        if(!isRectInViewport(camera, dst_rect)){
            // 超出视口就不进行绘制
            return;
        }

        // 绘制 (默认旋转中心为精灵的中心点)
        if(!SDL_RenderTextureRotated(sdl_renderer_, texture, &src_rect.value(), &dst_rect, 
        angle,nullptr, sprite.isFlipped() ? SDL_FLIP_HORIZONTAL : SDL_FLIP_NONE)){
            spdlog::error("绘制纹理失败了,info:{}", SDL_GetError());
        }

    }

    void Renderer::drawParallax(const Camera &camera, const Sprite &sprite, const glm::vec2 &position, const glm::vec2 &scroll_factor, const glm::bvec2 &repeat, const glm::vec2 &scale)
    {
        auto texture = resource_manager_->getTexture(sprite.getTextureId());
        if(!texture){
            spdlog::error("无法为ID {} 获得纹理。", sprite.getTextureId());
            return;
        }
        auto src_rect = getSpriteSrcRect(sprite);
        if(!src_rect){
            spdlog::error("无法为ID {} 获得源矩形。", sprite.getTextureId());
            return;
        }

        // 相机变换
        glm::vec2 position_screen = camera.worldToScreenWithParallax(position, scroll_factor);

        // 计算缩放尺寸
        float scaled_tex_w = src_rect.value().w * scale.x;
        float scaled_tex_h = src_rect.value().h * scale.y;

        // 绘制视差背景,根据repeat参数决定是否重复绘制
        glm::vec2 start, stop;
        glm::vec2 viewport_size = camera.getViewportSize();

        if(repeat.x){
            // 水平重复
            start.x = glm::mod(position_screen.x, scaled_tex_w) - scaled_tex_w;
            stop.x = viewport_size.x;
        } else {
            start.x = position_screen.x;
            stop.x = glm::min(position_screen.x + scaled_tex_w, viewport_size.x); // 防止超出视口
        }

        if(repeat.y){
            // 垂直重复
            start.y = glm::mod(position_screen.y, scaled_tex_h) - scaled_tex_h;
            stop.y = viewport_size.y;
        } else {
            start.y = position_screen.y;
            stop.y = glm::min(position_screen.y + scaled_tex_h, viewport_size.y); // 防止超出视口
        }

        for(float x = start.x; x < stop.x; x += scaled_tex_w){
            for(float y = start.y; y < stop.y; y += scaled_tex_h){
                SDL_FRect dst_rect = {
                    x,
                    y,
                    src_rect.value().w * scale.x,
                    src_rect.value().h * scale.y
                };
                if(!SDL_RenderTexture(sdl_renderer_, texture, &src_rect.value(), &dst_rect)){
                    spdlog::error("绘制纹理失败了,info:{}", SDL_GetError());
                    return;
                }
            }
        }

    }

    void Renderer::drawUISprite(const Sprite &sprite, const glm::vec2 &position, const std::optional<glm::vec2> size)
    {
        auto texture = resource_manager_->getTexture(sprite.getTextureId());
        if(!texture){
            spdlog::error("无法为ID {} 获得纹理。", sprite.getTextureId());
            return;
        }
        auto src_rect = getSpriteSrcRect(sprite);
        if(!src_rect){
            spdlog::error("无法为ID {} 获得源矩形。", sprite.getTextureId());
            return;
        }

        // 计算目标矩形, pos是左上角坐标
        SDL_FRect dst_rect = {
            position.x,
            position.y,
            size ? size.value().x : src_rect.value().w,
            size ? size.value().y : src_rect.value().h
        };

        if(!SDL_RenderTextureRotated(sdl_renderer_, texture, &src_rect.value(), &dst_rect,
            0.0,nullptr,sprite.isFlipped() ? SDL_FLIP_HORIZONTAL : SDL_FLIP_NONE)){
            spdlog::error("绘制纹理失败了,info:{}", SDL_GetError());
            return;
        }
    }

    void Renderer::present()
    {
        SDL_RenderPresent(sdl_renderer_);
    }

    void Renderer::clearScreen()
    {
       if(!SDL_RenderClear(sdl_renderer_)){
            spdlog::error("清除渲染器失败,info:{}", SDL_GetError());
       };
    }

    void Renderer::setDrawColor(Uint8 r, Uint8 g, Uint8 b, Uint8 a)
    {
        if(!SDL_SetRenderDrawColor(sdl_renderer_, r, g, b, a)){
            spdlog::error("设置绘制颜色失败了,info:{}", SDL_GetError());
        }
    }

    void Renderer::setDrawColorFloat(float r, float g, float b, float a)
    {
        if(!SDL_SetRenderDrawColorFloat(sdl_renderer_, r, g, b, a)){
            spdlog::error("设置绘制颜色失败了,info:{}", SDL_GetError());
        }
    }

    std::optional<SDL_FRect> Renderer::getSpriteSrcRect(const Sprite &sprite) const
    {
        SDL_Texture* texture = resource_manager_->getTexture(sprite.getTextureId());
        if(!texture){
            spdlog::error("无法为ID {} 获得纹理。", sprite.getTextureId());
            return std::nullopt;
        }
        auto src_rect = sprite.getSourceRect();
        if(src_rect){ // 如果源矩形存在 Sprite中存在指定rect, 则判断尺寸是否有效
            if(src_rect.value().w <= 0 || src_rect.value().h <= 0){
                spdlog::error("源矩形无效,宽度和高度必须大于0.");
                return std::nullopt;
            }
            return src_rect;
        } else { // 若为std::nullopt,则使用整个纹理
            SDL_FRect result = {0,0,0,0};
            if(!SDL_GetTextureSize(texture, &result.w, &result.h)){
                spdlog::error("源矩形rect获取失败");
                return std::nullopt;
            }
            return result;
        }
    }

    bool Renderer::isRectInViewport(const Camera &camera, const SDL_FRect &rect) const
    {
        glm::vec2 viewport_size = camera.getViewportSize(); // 获取视口尺寸
        // 相当于AABB碰撞检测
        if(rect.x >= viewport_size.x || rect.y >= viewport_size.y ||
            rect.x + rect.w <= 0 || rect.y + rect.h <= 0){
            return false;
        }
        return true;
    }
}
依赖关系

构造时接收SDL_Renderer*ResourceManager*,以便直接调用SDL并向资源管理器请求纹理。

主要方法
  • drawSprite() - 绘制受相机影响的世界物体;先做坐标转换,再进行视口裁剪,屏幕外则跳过节省资源
  • drawParallax() - 用于可重复的视差背景;根据相机位置与滚动因子计算需要绘制的背景块,实现无限滚动
  • drawUISpite() - 用于绘制固定在屏幕坐标系的UI(血条,菜单),不受相机影响。

6.集成&测试

GameApp中以unique_ptr持有RendererCamera,在init()阶段初始化(与TimeResourceManager模式一致)。

// game_app.h 新增
// ...
namespace engine::render{
class Camera;
class Renderer;
}

class GameApp final {
private:
    SDL_Window* window_ = nullptr;
    SDL_Renderer* sdl_renderer_ = nullptr; 
    bool is_running_ = false;

    // 引擎组件
    std::unique_ptr<engine::core::Time> time_;
    std::unique_ptr<engine::resource::ResourceManager> resource_manager_;
    std::unique_ptr<engine::render::Camera> camera_;
    std::unique_ptr<engine::render::Renderer> renderer_;

    [[nodiscard]] bool initRenderer();
    [[nodiscard]] bool initCamera();
    
    void testCamera();
    void testRenderer();
    
}
// game_app.cpp
//...
bool GameApp::init(){
    //...
    if(!initRenderer()) return false;
    if(!initCamera()) return false;
    //...
}
void GameApp::update(float)
{
    // 游戏逻辑更新
    testCamera();
}

void GameApp::render(){
	renderer_->clearScreen();
    testRenderer();
    renderer_->present();
}
bool GameApp::initSDL(){
    //...
    SDL_SetRenderLogicalPresentation(sdl_renderer_, 640,360, SDL_LOGICAL_PRESENTATION_LETTERBOX); // 设置渲染器逻辑分辨率
}

bool GameApp::initRenderer()
{
    try {
        renderer_ = std::make_unique<engine::render::Renderer>(sdl_renderer_, resource_manager_.get());
    } catch (const std::exception& e){
        spdlog::error("初始化Renderer失败: {}", e.what());
        return false;
    }
    return true;
}

bool GameApp::initCamera()
{
    try {
        camera_ = std::make_unique<engine::render::Camera>(glm::vec2(640,360));
    } catch (const std::exception& e){
        spdlog::error("初始化Camera失败: {}", e.what());
        return false;
    }
    return true;
}

void GameApp::testCamera()
{
    auto key_state = SDL_GetKeyboardState(nullptr);
    if(key_state[SDL_SCANCODE_W]){
        camera_->move(glm::vec2(0, -1));
    }
    if(key_state[SDL_SCANCODE_S]){
        camera_->move(glm::vec2(0, 1));
    }
    if(key_state[SDL_SCANCODE_A]){
        camera_->move(glm::vec2(-1, 0));
    }
    if(key_state[SDL_SCANCODE_D]){
        camera_->move(glm::vec2(1, 0));
    }
}

void GameApp::testRenderer()
{
    engine::render::Sprite sprite_world("assets/textures/Actors/frog.png");
    engine::render::Sprite sprite_ui("assets/textures/UI/buttons/Start1.png");
    engine::render::Sprite sprite_parallax("assets/textures/Layers/back.png");

    // 渲染顺序注意
    renderer_->drawParallax(*camera_, sprite_parallax, glm::vec2(0, 0), glm::vec2(0.3f,0.5f),{true,false});
    renderer_->drawSprite(*camera_,sprite_world, glm::vec2(250, 250));
    renderer_->drawUISprite(sprite_ui, glm::vec2(100, 100));
}

渲染流程

重构为三步:

  1. renderer_->clearScreen() - 用背景色清空画布
  2. testRenderer() - 发出所有具体绘制指令
  3. renderer_->present() - 将画布内容呈现到屏幕

测试函数

testCamera() - 用方向键移动相机,验证视图移动与边界限制

testRenderer() - 同时绘制背景(视差)、青蛙(世界)与按钮(UI),并让青蛙旋转,演示绘制方法

7.编译与运行

确保资源在正确的路径下

效果

image-20260118132618023

遇到的问题:

  1. renderer.cpp中编写drawParallax时,在判断startstop
// 绘制视差背景,根据repeat参数决定是否重复绘制
        glm::vec2 start, stop;
        glm::vec2 viewport_size = camera.getViewportSize();

        if(repeat.x){
            // 水平重复
            start.x = glm::mod(position_screen.x, scaled_tex_w) - scaled_tex_w;
            stop.x = viewport_size.x;
        } else {
            start.x = position_screen.x;
            stop.x = glm::min(position_screen.x + scaled_tex_w, viewport_size.x); // 防止超出视口
        }

        if(repeat.y){
            // 垂直重复
            start.y = glm::mod(position_screen.y, scaled_tex_h) - scaled_tex_h;
            stop.y = viewport_size.y;
        } else {
            start.y = position_screen.y;
            stop.y = glm::min(position_screen.y + scaled_tex_h, viewport_size.y); // 防止超出视口
        }

        for(float x = start.x; x < stop.x; x += scaled_tex_w){
            for(float y = start.y; y < stop.y; y += scaled_tex_h){
                SDL_FRect dst_rect = {
                    x,
                    y,
                    src_rect.value().w * scale.x,
                    src_rect.value().h * scale.y
                };
                if(!SDL_RenderTexture(sdl_renderer_, texture, &src_rect.value(), &dst_rect)){
                    spdlog::error("绘制纹理失败了,info:{}", SDL_GetError());
                    return;
                }
            }
        }

当时想的是比如要绘制一个对象在x方向重复,那么应该是贴合整个视口viewport的,比如一个需要绘制的对象是w = 200px,视口是800px,然后position_screen.x定在50px这个位置,那么这个该怎么绘制,或许应该从position_screen.x mod 200 = 50px这个位置开始绘制,但是这样左边就空了50px,所以要减去一个宽度texture_w,就从1-texture_w = -150px(start.x),这里开始绘制,然后一直加到(stop.x) 650px(最后一个650+200=850px填满viewport_size.x方向),当时错误的以为stop.x-150 + 800 = 650px,这样会导致650~800px这段空了出来,没有铺满

image-20260118133838256

  1. 对于renderer类中std::optional<SDL_FRect> getSpriteSrcRect(const Sprite& sprite) const; // 获取精灵的源矩形的疑惑

原本我以为这个函数感觉多此一举?因为在Sprite.h中有const std::optional<SDL_FRect>& getSourceRect() const { return source_rect_; } 但是后来发现不对,因为多数Texture使用的就是整张图片,所以这个函数得到的是std::nullopt,但是在通过renderer_绘制的应该需要源矩形 src_FRect 类似于{0,0,w,h}的东西,对应{x,y,w,h},但是你如果用sprite.getSourceRect()得到的就不对了,你拿到的是一个std::nullopt,所以我们在renderer类中写一个这样的函数得到{0,0,w,h}这样的SDL_FRect,实现了解耦,而且这个功能确实应该是renderer该做的。

  1. 这里简单讲讲3中draw的区别和理解

首先,绘制需要的sdl_rendererResourceManager不必多说,drawSpritedrawParallax都需要一个camera,你知道的,因为这些都涉及到世界坐标和屏幕坐标的转换,所以需要使用到相机的位置,而drawUISprite只是绘制在屏幕上。

  1. Camera类的clampPosition函数
void Camera::clampPosition()
    {
        if(limit_bounds_.has_value() && limit_bounds_->size.x > 0 && limit_bounds_->size.y > 0){
            // 计算允许的相机位置范围
            glm::vec2 min_pos = limit_bounds_->position;
            glm::vec2 max_pos = limit_bounds_->position + limit_bounds_->size - viewport_size_;
            
            // 如果世界大小小于视口大小呢? 这样min_pos 就比 max_pos 大了,max_pos < (0,0)
            // 我要保证max_pos 一定是大的
            max_pos.x = std::max(min_pos.x, max_pos.x);
            max_pos.y = std::max(min_pos.y, max_pos.y);

            position_ = glm::clamp(position_, min_pos, max_pos);

        }
    }

这里注意的就是限制相机的位置,如果世界比相机还小就会出现异常,比如世界是500×500px,相机是800×800,那么我进来时候比如相机position是在(0,0),显然,它包住了世界,但是一般可能会去计算得到一个(500- 800,500-800) = (-300,-300)的位置,这是max_pos,所以需要多判断一步,或者在初始化Camera使用的时候做好判断,如果相机视口比世界还大的话,就需要一些额外的设置了

posted @ 2026-01-21 10:29  逆行的乌鸦  阅读(1)  评论(0)    收藏  举报