第 17 章:Graphics、Camera 与多视图行动

第 17 章:Graphics、Camera 与多视图行动:保留第三版项目代码讲解,并以源码—状态—输出切片、确定性轨迹和故障重放完成验收。

学习目标

  • 能解释“第 17 章:Graphics、Camera 与多视图行动”如何集中 GameObject 绘制调用,并让主相机、雷达 View 与计时文本共享世界状态但使用各自观察边界
  • 能逐项定位 绘制调用(draw calls)、相机类(camera classes)、主视图(main view)、雷达视图(radar view)、计时文本(timer text),说明它们位于源码、运行状态还是可见输出边界
  • 能按 提交世界 → 设置主 View → 绘制主画面 → 设置雷达 View → 绘制 HUD 重放“同帧主画面与雷达”,持续检查“同一世界快照可被不同 View 绘制,绘制不反向修改游戏状态,计时文本使用游戏时钟而非渲染次数”
  • 能注入“为雷达绘制再次调用 update,使对象在一个显示帧内推进两次”,从世界版本号、update 次数、draw call 序列、各 View 参数、viewport 和计时文本值找到第一个不一致并用同输入恢复

第三版来源、工具链与版本边界

“第 17 章:Graphics、Camera 与多视图行动”对齐 Packt 2024 年第三版的对应章节范围,并以官方公开代码仓库和 SFML 官方文档核对本页可公开验证的工程事实。本页是独立中文重写,不复现原书正文,也不把商品页、目录或代码仓库冒充完整原版。

在“第 17 章:Graphics、Camera 与多视图行动”中,第三版示例使用 SFML 2.6.1 时代 API。本页保留与本章有关的 2.6 系列合同;SFML 3 的事件、角度、时长和构造接口差异不会被静默回填。升级工具链时必须单独记录本章迁移补丁,不能把版本不匹配误判为“绘制调用(draw calls)”概念错误。

  • Packt:Beginning C++ Game Programming, Third Edition:在“第 17 章:Graphics、Camera 与多视图行动”中,核对 2024 年第三版、C++20、SFML、四个项目、21 个正式教学章节及章节次序;不把商品页当作正文全文。
  • PacktPublishing:第三版官方代码仓库:在“第 17 章:Graphics、Camera 与多视图行动”中,核对 Timber、Pong、ZombieShooter、Run 四个项目的公开代码与资源组织;代码许可证不等于原书正文授权。
  • SFML 2.6 官方教程:在“第 17 章:Graphics、Camera 与多视图行动”中,核对本书使用的 SFML 2.6 系列 Window、Graphics、View、VertexArray、Shader 与 Audio API 语义。
  • SFML 2.6.1:sf::View 官方参考:在“第 17 章:Graphics、Camera 与多视图行动”中,核对 source rectangle 到 target viewport 的映射、主视图、HUD 与雷达视图边界。

正式概念与运行状态合同

绘制调用(draw calls)

这个正式目录节点落在 世界快照 边界:已提交的 GameObject 与游戏时间。在本页中,它参与“集中 GameObject 绘制调用,并让主相机、雷达 View 与计时文本共享世界状态但使用各自观察边界”。验收时保存世界版本号、update 次数、draw call 序列、各 View 参数、viewport 和计时文本值,不能只凭最终画面判断。

相机类(camera classes)

这个正式目录节点落在 观察系统 边界:主相机、雷达 View、裁剪和 viewport。在本页中,它参与“集中 GameObject 绘制调用,并让主相机、雷达 View 与计时文本共享世界状态但使用各自观察边界”。验收时保存同一世界快照可被不同 View 绘制,绘制不反向修改游戏状态,计时文本使用游戏时钟而非渲染次数,不能只凭最终画面判断。

主视图(main view)

这个正式目录节点落在 绘制提交 边界:draw calls、层次与 timer text。在本页中,它参与“集中 GameObject 绘制调用,并让主相机、雷达 View 与计时文本共享世界状态但使用各自观察边界”。验收时保存世界版本号、update 次数、draw call 序列、各 View 参数、viewport 和计时文本值,不能只凭最终画面判断。

雷达视图(radar view)

这个正式目录节点落在 世界快照 边界:已提交的 GameObject 与游戏时间。在本页中,它参与“集中 GameObject 绘制调用,并让主相机、雷达 View 与计时文本共享世界状态但使用各自观察边界”。验收时保存同一世界快照可被不同 View 绘制,绘制不反向修改游戏状态,计时文本使用游戏时钟而非渲染次数,不能只凭最终画面判断。

计时文本(timer text)

这个正式目录节点落在 观察系统 边界:主相机、雷达 View、裁剪和 viewport。在本页中,它参与“集中 GameObject 绘制调用,并让主相机、雷达 View 与计时文本共享世界状态但使用各自观察边界”。验收时保存世界版本号、update 次数、draw call 序列、各 View 参数、viewport 和计时文本值,不能只凭最终画面判断。

验收项本页合同
最小正常场景固定一个世界快照,依次使用主 View 和雷达 View 绘制
边界或恢复场景暂停游戏但继续重绘窗口和相机
必须保持同一世界快照可被不同 View 绘制,绘制不反向修改游戏状态,计时文本使用游戏时钟而非渲染次数
单一故障为雷达绘制再次调用 update,使对象在一个显示帧内推进两次
可观察证据世界版本号、update 次数、draw call 序列、各 View 参数、viewport 和计时文本值

先预测,再操作三个本页实验

实验一:从源码到可见结果

先预测“同帧主画面与雷达”会怎样穿过 世界快照 → 观察系统 → 绘制提交,再切换场景和正式概念。每次操作都必须能回到同一初始状态。

Source · state · visible result

第 17 章:Graphics、Camera 与多视图行动:可运行切片

集中 GameObject 绘制调用,并让主相机、雷达 View 与计时文本共享世界状态但使用各自观察边界

选择输入或构建场景

定位正式概念

bcgp3-17 · 当前切片

绘制调用(draw calls)固定一个世界快照,依次使用主 View 和雷达 View 绘制

边界 1世界快照

已提交的 GameObject 与游戏时间

边界 2观察系统

主相机、雷达 View、裁剪和 viewport

边界 3绘制提交

draw calls、层次与 timer text

可验收结果

对象只更新一次但以两种观察范围出现

实验二:逐步执行状态轨迹

依次执行 提交世界 → 设置主 View → 绘制主画面 → 设置雷达 View → 绘制 HUD。每一步只选中一个阶段,并持续检查“同一世界快照可被不同 View 绘制,绘制不反向修改游戏状态,计时文本使用游戏时钟而非渲染次数”。

Deterministic state trace

第 17 章:Graphics、Camera 与多视图行动:状态执行轨迹

逐步执行当前 1 / 5
始终保持

同一世界快照可被不同 View 绘制,绘制不反向修改游戏状态,计时文本使用游戏时钟而非渲染次数

本步证据

世界版本号、update 次数、draw call 序列、各 View 参数、viewport 和计时文本值

实验三:单一故障与同输入恢复

注入“为雷达绘制再次调用 update,使对象在一个显示帧内推进两次”,定位第一项不一致;撤销后用完全相同的“暂停计时”重放。只有中间状态和最终输出一起恢复才算修复。

Fault injection · clean replay

第 17 章:Graphics、Camera 与多视图行动:故障注入与恢复

单一故障:为雷达绘制再次调用 update,使对象在一个显示帧内推进两次

1. 固定构建与输入一致

第 1 次重放使用同一源码、资源、初始状态和输入序列

2. 程序状态一致

同一世界快照可被不同 View 绘制,绘制不反向修改游戏状态,计时文本使用游戏时钟而非渲染次数

3. 诊断证据一致

世界版本号、update 次数、draw call 序列、各 View 参数、viewport 和计时文本值

4. 恢复判断一致

同输入重放后,所有权、状态更新、可见输出和诊断证据重新一致

易错边界与工程取舍

从“一个世界为何能被看两次”开始

第 16 章在逻辑提交后生成稳定快照。本章的

不再推进 Player,也不重新判断碰撞。同一世界可以先通过主相机绘制,再通过雷达相机缩小绘制;两次观察不能让对象 update 两次。

数量重要但不是唯一指标。纹理切换、顶点数量、过度绘制和文字重建也可能更贵,优化前先测量。

Graphics 行为提交数据而非控制窗口

若每个对象都直接 window.setView(),最终渲染顺序会取决于对象排列。让 Graphics 只提交带层级、材质和包围盒的数据,Renderer 决定在哪个相机下执行。

struct DrawItem {
    RenderLayer layer;
    TextureId texture;
    sf::FloatRect worldBounds;
    sf::Transform transform;
    sf::IntRect textureRect;
};
 
class GraphicsBehavior {
public:
    virtual ~GraphicsBehavior() = default;
    virtual void submit(const RenderSnapshot& snapshot,
                        std::vector<DrawItem>& out) const = 0;
};

保证相机和渲染读取同一提交时刻。若 draw 直接读取正在被 update 改写的对象,多线程或分阶段渲染会出现半旧半新的撕裂状态。

收集、剔除、排序、执行

二维项目可用矩形相交完成。主视图和雷达范围不同,所以各自剔除;不能把主视图剔除结果直接复用给雷达。

void Renderer::draw(sf::RenderTarget& target,
                    const Camera& camera,
                    std::span<const DrawItem> items)
{
    target.setView(camera.view());
    scratch_.clear();
    for (const DrawItem& item : items) {
        if (camera.visibleBounds().intersects(item.worldBounds))
            scratch_.push_back(&item);
    }
    std::stable_sort(scratch_.begin(), scratch_.end(), drawOrder);
    for (const DrawItem* item : scratch_)
        drawItem(target, *item);
}

优先保证正确遮挡,再在同层按材质排序减少状态切换。透明对象不能随意重排;需要按混合语义或深度保持顺序。

Camera 类封装观察规则

不拥有 Player,只接收目标的提交后位置。resize 时重新计算 View 尺寸和 viewport,玩法世界单位不因窗口像素改变。

class Camera {
public:
    void follow(sf::Vector2f target, float dt);
    void resize(sf::Vector2u windowSize);
 
    const sf::View& view() const { return view_; }
    sf::FloatRect visibleBounds() const;
 
private:
    sf::View view_;
    sf::FloatRect worldLimits_;
    float smoothing_{8.0F};
};

钳制时要考虑半个 View 尺寸;若世界比 View 更小,应居中而不是让 min 大于 max。

main view:跟随、前视与稳定性

可在移动方向增加少量前视,使玩家看到即将到来的平台。前视来自稳定速度或朝向,不应直接对单帧输入抖动响应。

void Camera::follow(sf::Vector2f target, float dt)
{
    const sf::Vector2f current{view_.getCenter()};
    const float alpha{1.0F - std::exp(-smoothing_ * dt)};
    const sf::Vector2f desired{clampToWorld(target)};
    view_.setCenter(current + (desired - current) * alpha);
}

暂停时相机通常冻结;恢复前重启帧时钟,避免超大 dt 让相机瞬移。若玩法要求精确观察,调试模式可关闭平滑。

radar view:同一快照的第二次投影

并非把主画面截图缩小。它使用独立 Camera、独立可见范围和简化 Graphics 表现,只绘制对导航有意义的项目。

sf::View makeRadarView(sf::Vector2f center)
{
    sf::View radar{center, {1600.0F, 900.0F}};
    radar.setViewport({0.74F, 0.04F, 0.23F, 0.23F});
    return radar;
}

使雷达随分辨率保持相对位置。雷达边框属于 HUD,应在 radar 世界内容后切回 HUD View 再绘制,避免边框随雷达缩放。

timer text:时间状态与显示分离

真正状态只有 elapsed。分钟、秒和字符串都是派生值,避免多个计数器不同步。

void TimerHud::update(float elapsedSeconds)
{
    const int total{std::max(0, static_cast<int>(elapsedSeconds))};
    const int minutes{total / 60};
    const int seconds{total % 60};
 
    std::ostringstream text;
    text << std::setfill('0') << std::setw(2) << minutes
         << ':' << std::setw(2) << seconds;
    timer_.setString(text.str());
    recenterOrigin(timer_);
}

每次字符串变化后重算 local bounds,再以 HUD 逻辑宽度中心锚定。Font 由更长寿命资源所有者保存。

计时状态协议

通常只在 Playing 累加受限 dt;暂停、菜单和终局冻结。新局把 elapsed 清零,继续游戏不清零,重启帧时钟防止停留时间注入。若排行榜按真实完成时间计分,应明确暂停是否计时,不能由实现偶然决定。

让 timer text 在窗口 resize 后仍居中。先预测:若只在初始化时设置 origin,从 09:59 变成 10:00 时文本中心是否仍在同一点?

固定渲染顺序与测量

推荐顺序:clear;main Camera 剔除并绘制世界;radar Camera 绘制简化项目;HUD View 绘制雷达边框和 timer text;最后绘制暂停或终局覆盖层;display。整帧只 clear/display 一次。

分别记录 main 与 radar,避免把雷达的第二次绘制误判为重复 bug。性能回归测试使用同一快照和相机配置,比较实际 calls,而不是只凭肉眼看帧率。

坐标转换与 resize 诊断

本章的 camera classes(相机类)不只是保存两个 sf::View,还必须明确三套坐标:世界坐标描述 Player 与平台;归一化 viewport 描述一个 View 占窗口的比例;HUD 逻辑坐标描述 timer text 和边框。鼠标像素要先用目标 View 做 mapPixelToCoords,不能拿窗口像素直接和世界包围盒比较。雷达点击若使用 main view 转换,会得到看似合理但完全错误的世界位置。

窗口 resize 后先决定显示策略。若保持固定世界高度,就按新宽高比调整 main view 宽度;若使用 letterbox,就计算新的归一化 viewport 并接受黑边。两种策略都应让 Camera::resize 独立完成,Renderer 只读取结果。radar view 可以保持自己的世界尺寸,但小 viewport 需要检查宽高比,否则圆形标记会被拉成椭圆。HUD View 则重建为固定逻辑尺寸,再让锚点系统重新放置计时文本与雷达边框。

诊断画面错位时,记录当前通道名、View center、View size、viewport 和待绘制对象的 worldBounds。若 main 正常而 radar 空白,先确认 radar 剔除使用自己的 visibleBounds;若两者都偏移,检查 RenderSnapshot 的 Transform 是否已经是世界坐标;若只有 HUD 偏移,检查最后一次 setView 与 resize 后的 HUD 逻辑尺寸。这样能沿坐标边界定位,而不是反复调整魔法像素。

还应验证极端窗口:窄屏、超宽屏、最小可用尺寸和高 DPI 缩放。断言 viewport 每个分量都在合法比例范围,View size 为正,世界边界钳制不会产生反向区间。测试用固定 Player 位置分别经过 main、radar 和 HUD 转换,再反向映射,允许浮点误差但必须回到同一语义坐标。

验证清单

  • Player 静止、移动和瞬移时,main Camera 的边界、平滑和前视符合协议。
  • main view 剔除的远处对象若在 radar 范围内,仍会出现在 radar view。
  • resize 后两个 viewport 比例不变,HUD timer text 仍以逻辑屏幕居中。
  • 09:5910:00 文本不跳位,暂停阶段 elapsed 不增加。
  • 随机打乱 DrawItem 输入顺序后,RenderLayer 遮挡结果保持一致。
  • 统计候选、剔除、绘制与材质切换数量,确认优化改变的是预期指标。

小结

  • Graphics 行为从 RenderSnapshot 生成 DrawItem,Renderer 统一产生 draw calls
  • Camera 类封装 View、可见范围、跟随和 viewport,不能拥有玩法对象
  • main view 与 radar view 观察同一提交快照,但各自剔除和选择表现
  • timer text 从单一 elapsed 派生,使用固定宽度格式和 HUD 锚点
  • 每个渲染通道显式设置 View,并用渲染计数器验证优化

资料与写作方式声明

本章以Beginning C++ Game Programming, Third Edition, Chapter 17权威目录界定学习范围,并结合正文列出的技术资料独立重写;不宣称复现原书正文,也不沿用原作表述。

原作版权归作者与出版社所有;本站原创教学结构与表述仅供学习交流。

名词解释

本章出现的专业名词,用大白话再讲一遍。

Graphics 阶段

把已提交世界状态转换为可见图形的只读表现阶段。

draw call

一次向 RenderTarget 提交可绘制对象的操作。

Graphics 行为

读取快照并生成 DrawItem、不拥有窗口的对象行为。

RenderSnapshot

本帧绘制需要的只读位置与外观快照。

视锥剔除

按相机可见矩形过滤完全位于视野外项目的过程。

RenderLayer

保证背景、实体、效果和前景正确遮挡的语义层级。

Camera 类

拥有 View、可见范围和跟随策略的观察对象。

相机边界

限制 View 中心不越过世界有效范围的约束。

main view

占据窗口主体并跟随 Player 的世界视图。

平滑跟随

用与帧率无关插值让相机逐步接近目标。

radar view

使用更大世界范围和小 viewport 的辅助视图。

viewport

以窗口比例表示 View 输出区域的位置和尺寸。

timer text

从游戏经过时间派生并显示在 HUD 的计时文本。

固定宽度格式

用零填充保持分钟秒数字符宽度稳定的格式。

计时协议

规定各游戏阶段是否累加经过时间的规则。

HUD 锚点

按逻辑屏幕边缘或中心重新定位控件的布局方式。

渲染通道

使用明确 View 和顺序绘制一类内容的阶段。

渲染计数器

统计候选、剔除、调用和材质切换的诊断数据。

练习

  1. 问题 1:设计双相机帧。 写出 main、radar、HUD 三个 View 的绘制顺序,并说明各自剔除集合为何不能共用。
  1. 问题 2:诊断排序回归。 优化材质切换后透明效果遮挡错误,写出定位指标和修复边界。
  1. 问题 3:验证计时布局。 设计覆盖暂停、分钟进位和窗口 resize 的 timer text 测试。

讨论

评论区加载中…