Vulkan 教程 Khronos Vulkan® Tutorial · 中文翻译 GitHub

引言

本章将扩展 Vulkan 应用,在场景中渲染多个物体。到目前为止,渲染的一直是单个模型,但真实应用通常需要显示许多物体。本节将演示如何在尽可能复用资源的前提下,高效地管理和渲染多个物体。

概览

渲染多个物体时,需要考虑哪些资源应当: 1. 由所有物体共享——以减少内存占用和状态切换 2. 为每个物体单独复制——以便各自独立地定位和呈现外观

下面快速列出两类资源的典型归属:

共享资源:

  • 顶点缓冲区和索引缓冲区(多个物体使用同一网格时)

  • 纹理和采样器(多个物体使用同一纹理时)

  • 管线对象和管线布局

  • 渲染通道

  • 命令池

每个物体独有的资源:

  • 变换矩阵(位置、旋转、缩放)

  • 存放这些矩阵的统一缓冲区

  • 引用这些统一缓冲区的描述符集

  • 推送常量(用于体积小、频繁变化的数据)

实现

下面逐步说明渲染多个物体所需的关键改动:

定义 GameObject 结构

首先创建一个结构体来存放每个物体的数据:

// 定义用于存放每个物体数据的结构体
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;
    }
};

这个结构体封装了:

  • 物体的变换(位置、旋转、缩放)

  • 每个物体自己的统一缓冲区(每个在途帧一个)

  • 每个物体自己的描述符集(每个在途帧一个)

  • 用于计算模型矩阵的辅助方法

创建 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 已经用完它们。

性能考量

渲染多个物体时,请留意以下几点性能考量:

  1. 尽量减少状态切换:按材质或纹理给物体分组,以减少绑定变更。

  2. 对于大量相同的物体,使用实例化(本教程未涉及)。

  3. 对于体积小且频繁变化的数据,考虑改用推送常量,而不是统一缓冲区。

  4. 尽可能批量提交绘制调用,以降低 CPU 开销。

  5. 物体数量很大时使用间接绘制(此处未涉及)。

结语

至此,你已经学会了在 Vulkan 中渲染多个物体:

  1. 创建存放每个物体数据的结构体

  2. 为每个物体复制必要的资源(统一缓冲区、描述符集)

  3. 共享可以复用的资源(顶点缓冲区、索引缓冲区、管线、纹理)

  4. 更新渲染循环,用各自的变换绘制每个物体

这种方式让你可以独立地定位、旋转和缩放每个物体,同时通过按需共享资源来保持良好性能。

在真实应用中,你还可以在此基础上扩展出:

  • 物体层级结构(父子关系)

  • 为不同物体使用不同的网格和材质

  • 视锥剔除,避免渲染相机视野不到的物体

  • 针对不同距离物体的细节层次系统

你在这里打下的基础,将成为实践这些进阶技术的坚实起点。