// 定义用于存放每个物体数据的结构体
struct GameObject {
// 变换属性
glm::vec3 position = {0.0f, 0.0f, 0.0f};
glm::vec3 rotation = {0.0f, 0.0f, 0.0f};
glm::vec3 scale = {1.0f, 1.0f, 1.0f};
// 该物体的统一缓冲区(每个在途帧一个)
std::vector<vk::raii::Buffer> uniformBuffers;
std::vector<vk::raii::DeviceMemory> uniformBuffersMemory;
std::vector<void*> uniformBuffersMapped;
// 该物体的描述符集(每个在途帧一个)
std::vector<vk::raii::DescriptorSet> descriptorSets;
// 根据位置、旋转和缩放计算模型矩阵
glm::mat4 getModelMatrix() const {
glm::mat4 model = glm::mat4(1.0f);
model = glm::translate(model, position);
model = glm::rotate(model, rotation.x, glm::vec3(1.0f, 0.0f, 0.0f));
model = glm::rotate(model, rotation.y, glm::vec3(0.0f, 1.0f, 0.0f));
model = glm::rotate(model, rotation.z, glm::vec3(0.0f, 0.0f, 1.0f));
model = glm::scale(model, scale);
return model;
}
};
引言
本章将扩展 Vulkan 应用,在场景中渲染多个物体。到目前为止,渲染的一直是单个模型,但真实应用通常需要显示许多物体。本节将演示如何在尽可能复用资源的前提下,高效地管理和渲染多个物体。
概览
渲染多个物体时,需要考虑哪些资源应当: 1. 由所有物体共享——以减少内存占用和状态切换 2. 为每个物体单独复制——以便各自独立地定位和呈现外观
下面快速列出两类资源的典型归属:
共享资源:
-
顶点缓冲区和索引缓冲区(多个物体使用同一网格时)
-
纹理和采样器(多个物体使用同一纹理时)
-
管线对象和管线布局
-
渲染通道
-
命令池
每个物体独有的资源:
-
变换矩阵(位置、旋转、缩放)
-
存放这些矩阵的统一缓冲区
-
引用这些统一缓冲区的描述符集
-
推送常量(用于体积小、频繁变化的数据)
实现
下面逐步说明渲染多个物体所需的关键改动:
定义 GameObject 结构
首先创建一个结构体来存放每个物体的数据:
这个结构体封装了:
-
物体的变换(位置、旋转、缩放)
-
每个物体自己的统一缓冲区(每个在途帧一个)
-
每个物体自己的描述符集(每个在途帧一个)
-
用于计算模型矩阵的辅助方法
创建 GameObject 数组
在应用类中,用 GameObject 数组替换原先单一一套统一缓冲区和描述符集:
// 定义要渲染的物体数量
constexpr int MAX_OBJECTS = 3;
// 在 VulkanApplication 类中:
// 要渲染的游戏物体数组
std::array<GameObject, MAX_OBJECTS> gameObjects;
初始化 GameObject
新增一个方法,用不同的位置、旋转和缩放来设置各游戏物体:
// 用不同的位置、旋转和缩放初始化游戏物体
void setupGameObjects() {
// 物体 1 - 中心
gameObjects[0].position = {0.0f, 0.0f, 0.0f};
gameObjects[0].rotation = {0.0f, 0.0f, 0.0f};
gameObjects[0].scale = {1.0f, 1.0f, 1.0f};
// 物体 2 - 左侧
gameObjects[1].position = {-2.0f, 0.0f, -1.0f};
gameObjects[1].rotation = {0.0f, glm::radians(45.0f), 0.0f};
gameObjects[1].scale = {0.75f, 0.75f, 0.75f};
// 物体 3 - 右侧
gameObjects[2].position = {2.0f, 0.0f, -1.0f};
gameObjects[2].rotation = {0.0f, glm::radians(-45.0f), 0.0f};
gameObjects[2].scale = {0.75f, 0.75f, 0.75f};
}
该方法在 initVulkan() 中调用,位置在加载模型之后、创建统一缓冲区之前。
为每个物体创建统一缓冲区
不再只创建一套统一缓冲区,而是为每个物体各创建一套:
// 为每个物体创建统一缓冲区
void createUniformBuffers() {
// 遍历每个游戏物体
for (auto& gameObject : gameObjects) {
gameObject.uniformBuffers.clear();
gameObject.uniformBuffersMemory.clear();
gameObject.uniformBuffersMapped.clear();
// 为每个在途帧创建统一缓冲区
for (size_t i = 0; i < MAX_FRAMES_IN_FLIGHT; i++) {
vk::DeviceSize bufferSize = sizeof(UniformBufferObject);
vk::raii::Buffer buffer({});
vk::raii::DeviceMemory bufferMem({});
createBuffer(bufferSize, vk::BufferUsageFlagBits::eUniformBuffer,
vk::MemoryPropertyFlagBits::eHostVisible | vk::MemoryPropertyFlagBits::eHostCoherent,
buffer, bufferMem);
gameObject.uniformBuffers.emplace_back(std::move(buffer));
gameObject.uniformBuffersMemory.emplace_back(std::move(bufferMem));
gameObject.uniformBuffersMapped.emplace_back(gameObject.uniformBuffersMemory[i].mapMemory(0, bufferSize));
}
}
}
更新描述符池大小
需要增大描述符池的大小,才能容纳所有物体:
void createDescriptorPool() {
// 需要 MAX_OBJECTS * MAX_FRAMES_IN_FLIGHT 个描述符集
std::array poolSize {
vk::DescriptorPoolSize(vk::DescriptorType::eUniformBuffer, MAX_OBJECTS * MAX_FRAMES_IN_FLIGHT),
vk::DescriptorPoolSize(vk::DescriptorType::eCombinedImageSampler, MAX_OBJECTS * MAX_FRAMES_IN_FLIGHT)
};
vk::DescriptorPoolCreateInfo poolInfo{
.flags = vk::DescriptorPoolCreateFlagBits::eFreeDescriptorSet,
.maxSets = MAX_OBJECTS * MAX_FRAMES_IN_FLIGHT,
.poolSizeCount = static_cast<uint32_t>(poolSize.size()),
.pPoolSizes = poolSize.data()
};
descriptorPool = vk::raii::DescriptorPool(device, poolInfo);
}
为每个物体创建描述符集
同样地,为每个物体创建各自的描述符集:
void createDescriptorSets() {
// 遍历每个游戏物体
for (auto& gameObject : gameObjects) {
// 为每个在途帧创建描述符集
std::vector<vk::DescriptorSetLayout> layouts(MAX_FRAMES_IN_FLIGHT, *descriptorSetLayout);
vk::DescriptorSetAllocateInfo allocInfo{
.descriptorPool = *descriptorPool,
.descriptorSetCount = static_cast<uint32_t>(layouts.size()),
.pSetLayouts = layouts.data()
};
gameObject.descriptorSets.clear();
gameObject.descriptorSets = device.allocateDescriptorSets(allocInfo);
for (size_t i = 0; i < MAX_FRAMES_IN_FLIGHT; i++) {
vk::DescriptorBufferInfo bufferInfo{
.buffer = *gameObject.uniformBuffers[i],
.offset = 0,
.range = sizeof(UniformBufferObject)
};
vk::DescriptorImageInfo imageInfo{
.sampler = *textureSampler,
.imageView = *textureImageView,
.imageLayout = vk::ImageLayout::eShaderReadOnlyOptimal
};
std::array descriptorWrites{
vk::WriteDescriptorSet{
.dstSet = *gameObject.descriptorSets[i],
.dstBinding = 0,
.dstArrayElement = 0,
.descriptorCount = 1,
.descriptorType = vk::DescriptorType::eUniformBuffer,
.pBufferInfo = &bufferInfo
},
vk::WriteDescriptorSet{
.dstSet = *gameObject.descriptorSets[i],
.dstBinding = 1,
.dstArrayElement = 0,
.descriptorCount = 1,
.descriptorType = vk::DescriptorType::eCombinedImageSampler,
.pImageInfo = &imageInfo
}
};
device.updateDescriptorSets(descriptorWrites, {});
}
}
}
更新所有物体的统一缓冲区
修改统一缓冲区的更新逻辑,使其处理所有物体:
void updateUniformBuffers() {
static auto startTime = std::chrono::high_resolution_clock::now();
auto currentTime = std::chrono::high_resolution_clock::now();
float time = std::chrono::duration<float>(currentTime - startTime).count();
// 相机矩阵和投影矩阵(由所有物体共享)
glm::mat4 view = glm::lookAt(glm::vec3(2.0f, 2.0f, 6.0f), glm::vec3(0.0f, 0.0f, 0.0f), glm::vec3(0.0f, 1.0f, 0.0f));
glm::mat4 proj = glm::perspective(glm::radians(45.0f),
static_cast<float>(swapChainExtent.width) / static_cast<float>(swapChainExtent.height),
0.1f, 20.0f);
// 更新每个物体的统一缓冲区
for (auto& gameObject : gameObjects) {
// 让物体持续旋转
gameObject.rotation.y += 0.001f; // 绕 Y 轴缓慢旋转
// 取得该物体的模型矩阵
glm::mat4 initialRotation = glm::rotate(glm::mat4(1.0f), glm::radians(-90.0f), glm::vec3(1.0f, 0.0f, 0.0f));
glm::mat4 model = gameObject.getModelMatrix() * initialRotation;
// 创建并更新 UBO
UniformBufferObject ubo{
.model = model,
.view = view,
.proj = proj
};
// 把 UBO 数据复制到已映射的内存
memcpy(gameObject.uniformBuffersMapped[frameIndex], &ubo, sizeof(ubo));
}
}
注意,视图矩阵和投影矩阵由所有物体共享,但每个物体都有自己的模型矩阵。
修改命令缓冲区记录
最后,修改命令缓冲区的记录过程,逐个绘制物体:
void recordCommandBuffer(uint32_t imageIndex) {
// ...(方法开头保持不变)
// 绑定顶点缓冲区和索引缓冲区(由所有物体共享)
commandBuffers[frameIndex].bindVertexBuffers(0, *vertexBuffer, {0});
commandBuffers[frameIndex].bindIndexBuffer(*indexBuffer, 0, vk::IndexType::eUint32);
// 用各自的描述符集绘制每个物体
for (const auto& gameObject : gameObjects) {
// 绑定该物体的描述符集
commandBuffers[frameIndex].bindDescriptorSets(
vk::PipelineBindPoint::eGraphics,
*pipelineLayout,
0,
*gameObject.descriptorSets[frameIndex],
nullptr
);
// 绘制该物体
commandBuffers[frameIndex].drawIndexed(indices.size(), 1, 0, 0, 0);
}
// ...(方法结尾保持不变)
}
更新清理逻辑
现在每个 GameObject 都拥有自己的统一缓冲区,因此 cleanup 需要逐个解除映射并
释放它们,而不能再依赖前面章节里那套单独的统一缓冲区成员:
void cleanup() {
for (auto& gameObject : gameObjects) {
for (size_t i = 0; i < gameObject.uniformBuffersMemory.size(); i++) {
if (gameObject.uniformBuffersMapped[i] != nullptr) {
gameObject.uniformBuffersMemory[i].unmapMemory();
}
}
gameObject.uniformBuffers.clear();
gameObject.uniformBuffersMemory.clear();
gameObject.uniformBuffersMapped.clear();
gameObject.descriptorSets.clear();
}
glfwDestroyWindow(window);
glfwTerminate();
}
渲染循环退出后、cleanup 执行之前,mainLoop 已经调用了 device.waitIdle(),
因此在这里解除映射并释放这些资源是安全的——可以保证 GPU 已经用完它们。
性能考量
渲染多个物体时,请留意以下几点性能考量:
-
尽量减少状态切换:按材质或纹理给物体分组,以减少绑定变更。
-
对于大量相同的物体,使用实例化(本教程未涉及)。
-
对于体积小且频繁变化的数据,考虑改用推送常量,而不是统一缓冲区。
-
尽可能批量提交绘制调用,以降低 CPU 开销。
-
物体数量很大时使用间接绘制(此处未涉及)。
结语
至此,你已经学会了在 Vulkan 中渲染多个物体:
-
创建存放每个物体数据的结构体
-
为每个物体复制必要的资源(统一缓冲区、描述符集)
-
共享可以复用的资源(顶点缓冲区、索引缓冲区、管线、纹理)
-
更新渲染循环,用各自的变换绘制每个物体
这种方式让你可以独立地定位、旋转和缩放每个物体,同时通过按需共享资源来保持良好性能。
在真实应用中,你还可以在此基础上扩展出:
-
物体层级结构(父子关系)
-
为不同物体使用不同的网格和材质
-
视锥剔除,避免渲染相机视野不到的物体
-
针对不同距离物体的细节层次系统
你在这里打下的基础,将成为实践这些进阶技术的坚实起点。