第 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 绘制
已提交的 GameObject 与游戏时间
↓
主相机、雷达 View、裁剪和 viewport
↓
draw calls、层次与 timer text
可验收结果
对象只更新一次但以两种观察范围出现
实验二:逐步执行状态轨迹
依次执行 提交世界 → 设置主 View → 绘制主画面 → 设置雷达 View → 绘制 HUD。每一步只选中一个阶段,并持续检查“同一世界快照可被不同 View 绘制,绘制不反向修改游戏状态,计时文本使用游戏时钟而非渲染次数”。
Deterministic state trace
第 17 章:Graphics、Camera 与多视图行动:状态执行轨迹
同一世界快照可被不同 View 绘制,绘制不反向修改游戏状态,计时文本使用游戏时钟而非渲染次数
世界版本号、update 次数、draw call 序列、各 View 参数、viewport 和计时文本值
实验三:单一故障与同输入恢复
注入“为雷达绘制再次调用 update,使对象在一个显示帧内推进两次”,定位第一项不一致;撤销后用完全相同的“暂停计时”重放。只有中间状态和最终输出一起恢复才算修复。
Fault injection · clean replay
第 17 章:Graphics、Camera 与多视图行动:故障注入与恢复
单一故障:为雷达绘制再次调用 update,使对象在一个显示帧内推进两次
第 1 次重放使用同一源码、资源、初始状态和输入序列
同一世界快照可被不同 View 绘制,绘制不反向修改游戏状态,计时文本使用游戏时钟而非渲染次数
世界版本号、update 次数、draw call 序列、各 View 参数、viewport 和计时文本值
同输入重放后,所有权、状态更新、可见输出和诊断证据重新一致
易错边界与工程取舍
从“一个世界为何能被看两次”开始
第 16 章在逻辑提交后生成稳定快照。本章的
↡把已提交世界状态转换成可见精灵、形状和文本的只读表现阶段。不再推进 Player,也不重新判断碰撞。同一世界可以先通过主相机绘制,再通过雷达相机缩小绘制;两次观察不能让对象 update 两次。
↡一次向 RenderTarget 提交可绘制对象的操作,是测量渲染工作量的基本单位之一。数量重要但不是唯一指标。纹理切换、顶点数量、过度绘制和文字重建也可能更贵,优化前先测量。
Graphics 行为提交数据而非控制窗口
↡GameObject 中读取 Transform 与外观状态、生成 DrawItem,但不拥有窗口或修改游戏逻辑的行为。若每个对象都直接 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 改写的对象,多线程或分阶段渲染会出现半旧半新的撕裂状态。
收集、剔除、排序、执行
↡使用相机世界可见矩形排除完全位于视野外 DrawItem 的阶段。二维项目可用矩形相交完成。主视图和雷达范围不同,所以各自剔除;不能把主视图剔除结果直接复用给雷达。
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 类封装观察规则
↡拥有 sf::View、世界可见范围和跟随策略,并把世界坐标映射到指定窗口 viewport 的对象。不拥有 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:跟随、前视与稳定性
↡占据窗口主体、跟随 Player 展示当前跑酷区域的世界视图。可在移动方向增加少量前视,使玩家看到即将到来的平台。前视来自稳定速度或朝向,不应直接对单帧输入抖动响应。
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:同一快照的第二次投影
↡以更大世界范围和右上角小 viewport 展示玩家、平台及关键威胁的辅助视图。并非把主画面截图缩小。它使用独立 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:时间状态与显示分离
↡从已提交游戏经过时间派生的分钟秒数字符串,固定显示在 HUD View 中。真正状态只有 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、Paused、Home 和 GameOver 各阶段是否累加 elapsed 的规则。通常只在 Playing 累加受限 dt;暂停、菜单和终局冻结。新局把 elapsed 清零,继续游戏不清零,重启帧时钟防止停留时间注入。若排行榜按真实完成时间计分,应明确暂停是否计时,不能由实现偶然决定。
↡以 HUD 逻辑屏幕边缘或中心为参考,在文本尺寸变化后重新计算稳定位置的布局方式。让 timer text 在窗口 resize 后仍居中。先预测:若只在初始化时设置 origin,从 09:59 变成 10:00 时文本中心是否仍在同一点?
固定渲染顺序与测量
↡主世界、雷达世界、HUD 与覆盖层在每帧使用明确 View 和先后顺序执行的协议。推荐顺序: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:59到10:00文本不跳位,暂停阶段 elapsed 不增加。 - 随机打乱 DrawItem 输入顺序后,RenderLayer 遮挡结果保持一致。
- 统计候选、剔除、绘制与材质切换数量,确认优化改变的是预期指标。
小结
- Graphics 行为从 RenderSnapshot 生成 DrawItem,Renderer 统一产生 draw calls
- Camera 类封装 View、可见范围、跟随和 viewport,不能拥有玩法对象
- main view 与 radar view 观察同一提交快照,但各自剔除和选择表现
- timer text 从单一 elapsed 派生,使用固定宽度格式和 HUD 锚点
- 每个渲染通道显式设置 View,并用渲染计数器验证优化
名词解释
本章出现的专业名词,用大白话再讲一遍。
- 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:设计双相机帧。 写出 main、radar、HUD 三个 View 的绘制顺序,并说明各自剔除集合为何不能共用。
- 问题 2:诊断排序回归。 优化材质切换后透明效果遮挡错误,写出定位指标和修复边界。
- 问题 3:验证计时布局。 设计覆盖暂停、分钟进位和窗口 resize 的 timer text 测试。