第 9 章:引用、精灵图集与 VertexArray

对齐官方第 3 版第 9 章:用 C++ 引用构建调用者持有的背景,理解 sprite sheet 和 sf::VertexArray,生成可复现的随机滚动世界。

学习目标

  • 能区分值、可写引用和 const 引用的修改与生命周期契约,为背景构建函数设计安全签名
  • 能实现 sprite sheet 图块编号到世界顶点和纹理坐标的映射,用 sf::VertexArray 一次提交地面
  • 能设计固定种子可重放、出生区合法且与竞技场边界一致的随机滚动背景,并验证接缝与范围

从“函数怎样把一整片地形交回调用者”开始

第 8 章的 worldView 能在大世界滚动,但背景仍是占位对象。第 9 章用

让构建函数填充调用者拥有的 VertexArray,同时读取图集和随机引擎。核心问题是修改权限和生命周期,不是“引用永远更快”。

值、可写引用与 const 引用

用于行列数、tile 尺寸等小值。 用于 sf::VertexArray& output 用于 Texture 与配置。

bool buildBackground(sf::VertexArray& output,
                     const sf::Texture& atlas,
                     std::mt19937& random,
                     std::size_t rows,
                     std::size_t columns,
                     float tileSize);

随机引擎也是可写引用,因为抽样会推进其序列状态。函数不保存这些引用,调用结束后 output、atlas 和 random 仍由调用者拥有。引用非空,但不自动保证对象仍存活;返回引用或保存成员引用时必须额外证明生命周期。

引用一旦绑定不能改绑,给引用赋另一个对象执行的是“给被引用对象赋值”,不是让引用指向新对象。若依赖可缺失或运行期需要切换目标,应使用能表达空值的指针、optional<reference_wrapper<T>> 或更高层句柄。无论引用还是指针都不自动表示所有权;背景对象若长期保存 Texture 借用,拥有它的游戏资源层必须保证先销毁背景、后销毁纹理,并禁止资源热重载留下悬空地址。

API 文档应分别写“是否可空”“是否修改”“是否保存”“谁拥有”,不能用一个 & 让读者猜四件事。当前 buildBackground 只在调用期借用,因此最容易验证。

精灵图集:一张纹理保存多个 tile

减少纹理切换和文件管理。假设每格 64x64、图集每行 8 格,tile id 13 对应列 13 % 8 = 5、行 13 / 8 = 1,纹理左上为 (320,64)

struct AtlasLayout {
    std::size_t columns;
    std::size_t rows;
    unsigned tilePixels;
};
 
sf::Vector2f tileTexOrigin(std::size_t id, const AtlasLayout& atlas)
{
    const std::size_t tileCount{atlas.columns * atlas.rows};
    if (atlas.columns == 0 || id >= tileCount)
        throw std::out_of_range{"tile id outside atlas"};
 
    return {
        static_cast<float>((id % atlas.columns) * atlas.tilePixels),
        static_cast<float>((id / atlas.columns) * atlas.tilePixels)
    };
}

来自地图数据或随机生成器。图集尺寸应与布局配置交叉验证,不能访问纹理外坐标;边缘过滤产生接缝时,还要检查采样方式、padding 和像素对齐。

VertexArray:四个顶点组成一个地块

设置为 sf::Quads 时,每个 tile 使用四个顶点。每个 Vertex 同时保存世界 position、纹理 texCoords 和可选颜色。

const std::size_t tileIndex{row * columns + column};
sf::Vertex* quad{&vertices[tileIndex * 4]};
 
const float x{static_cast<float>(column) * tileSize};
const float y{static_cast<float>(row) * tileSize};
quad[0].position = {x, y};
quad[1].position = {x + tileSize, y};
quad[2].position = {x + tileSize, y + tileSize};
quad[3].position = {x, y + tileSize};

按一致绕序排列。rows * columns * 4 前先检查 size_t 乘法溢出和合理上限,再 resize;否则恶意或损坏地图尺寸可导致小分配后越界写。

纹理坐标:从 tile id 选择图集区域

使用图块左上 (u,v) 和 tile 像素尺寸填四角。

const sf::Vector2f uv{tileTexOrigin(tileId, atlasLayout)};
const float pixels{static_cast<float>(atlasLayout.tilePixels)};
 
quad[0].texCoords = uv;
quad[1].texCoords = {uv.x + pixels, uv.y};
quad[2].texCoords = {uv.x + pixels, uv.y + pixels};
quad[3].texCoords = {uv.x, uv.y + pixels};

世界 tileSize 可以与纹理 tilePixels 不同,表示缩放;不要把两者混为同一变量。纹理启用平滑过滤时,相邻图块颜色可能在边界混合,图集可加 padding,或根据像素美术策略关闭 smoothing。

批处理:一次 draw 不等于零成本

让整个地面通过一个 VertexArray 和一张 atlas 绘制:

sf::RenderStates states;
states.texture = &terrainAtlas;
 
window.setView(worldView);
window.draw(terrainVertices, states);

VertexArray 不拥有 Texture,terrainAtlas 必须覆盖绘制期。一次 draw 仍要处理所有顶点;大地图若每帧提交全量不可见几何,会增加顶点处理与带宽。后续可按 chunk 分区并只绘制 View 可见块,但先测量,不要在地图很小时增加复杂度。

动态图块只修改对应四顶点,不必重建全部数组。地图数据仍应与渲染顶点分离,碰撞和寻路读取 tile id,不从 texCoords 反推地形类型。

随机背景:种子、规则与出生区

不是每帧随机。关卡开始时生成 tile id,再构建 VertexArray;相机滚动只改变 View,背景顶点保持世界坐标。

std::uniform_int_distribution<int> tileChoice{0, 99};
 
for (std::size_t row{0}; row < rows; ++row) {
    for (std::size_t column{0}; column < columns; ++column) {
        const bool inSpawnZone{row >= spawnTop && row <= spawnBottom &&
                               column >= spawnLeft && column <= spawnRight};
        const int roll{tileChoice(random)};
        const std::size_t tileId{inSpawnZone ? grassTile
                                             : (roll < 85 ? grassTile : wallTile)};
        writeTile(vertices, row, column, columns, tileSize,
                  tileId, atlasLayout);
    }
}

出生区强制可走,避免 Player 生成在障碍内。真实地图还要验证出口、连通性、僵尸生成距离和可见遮挡;概率落在范围不等于关卡可玩。固定种子用于测试,正式运行记录种子便于复现。

应进入日志或存档。生成器规则版本也要记录,否则算法更新后同一 seed 不再代表同一地图。

世界边界必须来自同一配置

竞技场宽度是 columns * tileSize,高度是 rows * tileSize。Player 边界、相机钳制、僵尸生成和 VertexArray 顶点都从这一配置派生,不能一处写 3000、另一处按 50 格乘 64 得 3200。

const float worldWidth{static_cast<float>(columns) * tileSize};
const float worldHeight{static_cast<float>(rows) * tileSize};
const sf::FloatRect arena{0.0F, 0.0F, worldWidth, worldHeight};

浮点转换前检查列行与 tileSize 的范围。地图数据索引使用 size_t,世界坐标使用 float;跨类型边界集中在构建阶段,运行循环不反复猜转换。

函数契约与失败提交

buildBackground 若中途发现 atlas 配置或尺寸非法,不应留下看似完整的半数组。可先构建局部 VertexArray,全部验证成功后 move 到 output;或返回 std::optional<Background>,同时携带 arena 与 tile 数据。

sf::VertexArray candidate{sf::Quads};
candidate.resize(vertexCount);
// Fill and validate candidate.
output = std::move(candidate);
return true;

可写引用容易产生部分修改,因此后置条件要明确。单纯返回 VertexArray 值通常借助移动语义足够高效,也更容易表达成功提交;本章使用引用是为了学习别名,工程设计仍应比较错误协议和所有权清晰度。

验证几何、纹理和滚动

对 2x2 小地图使用固定 tile ids,断言顶点数 16、世界范围正确、四个 UV 角落位于图集对应单元。用错误 id、零列数、过大乘积和纹理尺寸不匹配验证失败路径。

随机测试用固定 seed,断言重复布局和 checksum 一致、所有 id 合法、出生区全 grass;不同 seed 不要求必然不同每一格,只检查规则。连通性用 flood fill 从出生点确认最低可走比例。

视觉测试画 2x2 彩色标记图集,检查旋转、镜像、接缝和绕序;让 Player 跨越一屏,背景应通过 worldView 平滑滚动,HUD 保持固定。性能比较每 tile Sprite 与单 VertexArray 的 draw call 和 CPU 时间,而不是只看平均 FPS。

先预测:背景函数接收 sf::VertexArray output 而不是引用,内部填满后调用者为何仍是空?形参是副本;应返回值或使用明确可写引用,并定义失败后状态。

小结

  • C++ 引用是对象别名,不延长生命周期;可写引用表达输出,const 引用表达只读借用
  • 精灵图集用 tile id 映射纹理单元,世界 tile 尺寸与纹理像素尺寸是独立概念
  • 每个 tile 使用四个 Vertex,位置和 texCoords 按一致绕序写入 VertexArray
  • 批处理减少 draw 调用,但 Texture 仍由外部拥有,大地图还可能需要 chunk 可见性裁剪
  • 随机背景在关卡开始生成,固定种子和规则版本负责重放,出生区与连通性需要额外验证
  • 顶点几何、Player 边界和相机范围都从同一 rows/columns/tileSize 配置派生

练习

问题 1 buildBackground 的 output、atlas、rng、rows 分别应怎样传递?说明修改和生命周期契约。

问题 2 图集 8 列、tile 64 像素,id 13 的 UV 四角是什么?世界 tileSize 为 96 时位置如何计算?

问题 3 怎样证明随机滚动背景既可复现又可玩?

名词解释

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

C++ 引用

绑定现有对象的别名,不复制、不重新指向,也不延长对象生命周期。

值传递

形参取得副本,函数内修改不影响调用者对象。

可写引用
T& 形参绑定并允许修改调用者对象。
const 引用

const T& 只读绑定调用者对象,避免复制且禁止经该引用修改。

精灵图集

把多个子图排列在同一纹理、按图块矩形选择的资源。

图块编号

标识图集中一个子图的整数,经列数换算为纹理行列。

sf::VertexArray

按图元类型保存一批 Vertex 并可一次 draw 提交的 SFML 集合。

顶点位置
顶点在游戏世界中的二维几何位置。
纹理坐标

顶点在纹理图像中的采样位置,SFML 二维场景通常使用像素坐标。

批处理

合并相同渲染状态图元以减少 draw 提交次数的技术。

随机滚动背景

一次生成在世界坐标中、由 worldView 观察滚动的随机地形。

地图种子

初始化地形伪随机序列并使布局可重放的值。

资料与写作方式声明

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

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

讨论

评论区加载中…