狸村 Mystic Web · 狸村
目录

引言

在本章中,我们将扩展 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);
    proj[1][1] *= -1; // 为 Vulkan 翻转 Y

    // 更新每个对象的统一缓冲
    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);
    }

    // ...(方法的结尾保持不变)
}

性能考虑

在渲染多个对象时,请记住以下性能考虑:

  1. 尽量减少状态更改:按材质/纹理对对象进行分组,以减少绑定更改。

  2. 对许多相同对象使用*实例化*(本教程未涵盖)。

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

  4. 尽可能*批量绘制调用*以减少 CPU 开销。

  5. 对于大量对象,使用*间接绘制*(此处未涵盖)。

结论

你现在已经学会了如何在 Vulkan 中渲染多个对象,方法如下:

  1. 创建用于保存每对象数据的结构体

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

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

  4. 更新渲染循环,以使用自己的变换绘制每个对象

这种方法使你可以灵活地独立定位、旋转和缩放对象,同时通过在适当的地方共享资源来保持良好的性能。

在实际应用程序中,你可能会通过以下方式扩展此系统:

  • 对象层次结构(父子关系)

  • 不同对象的不同网格和材质

  • 视锥体裁剪,避免渲染相机视图外的对象

  • 不同距离对象的细节级别系统

你在这里构建的基础将作为这些更高级技术的坚实起点。

Vulkan 是 Khronos Group Inc. 的注册商标

教程内容版权归原作者,遵循 CC BY-SA 4.0;本站独立代码及设计除外。