Vulkan 学习笔记14—模型加载(OBJ、glTF)
一、渲染层重构
VkRender.cpp 已经膨胀到1500行代码左右,网格渲染、纹理映射很多业务数据对象是直接挂在 VkContext 大对象上,不利于后期扩展。需要重构代码,封装基础网格对象,把顶点/颜色/纹理坐标/法线等统统打包到一个网格对象中(MeshInstance)。
1.VkTexture 修改
// 封装 Vulkan 纹理资源的结构体,管理 CPU 端图像数据和 GPU 端渲染资源
struct VkTexture {// 纹理资源的唯一标识符(通常是文件路径),用于资源查找和管理std::string assetId;// 存储纹理的原始像素数据(例如从 obj/glTF/glb 文件内嵌的图像数据)// 当纹理从外部文件加载时可能为空,仅在需要时保留(如动态生成纹理)std::vector<uint8_t> pixels;// 图像的尺寸信息:宽度、高度和通道数(如 RGB 为 3,RGBA 为 4)int width = 0;int height = 0;int channels = 0;// 以下是 Vulkan 纹理资源的核心句柄:// VkImage:表示 GPU 端的图像资源,存储纹理数据VkImage image = VK_NULL_HANDLE;// VkDeviceMemory:与图像关联的设备内存,用于存储图像数据VkDeviceMemory memory = VK_NULL_HANDLE;// VkImageView:图像的视图,定义了如何访问图像数据(如格式、层、级别)VkImageView view = VK_NULL_HANDLE;// VkSampler:采样器对象,定义了纹理采样时的过滤和寻址方式VkSampler sampler = VK_NULL_HANDLE;// 默认构造函数,使用编译器生成的默认实现VkTexture() = default;
};
2. Vertex 结构扩展
namespace renderer {...struct Vertex {...// 必须重载 operator== 用于键比较bool operator==(const Vertex& other) const {return pos == other.pos && color == other.color && texCoord == other.texCoord;}};...
} // namespace renderer// 特化哈希函数
namespace std {template <>struct hash<renderer::Vertex> {size_t operator()(renderer::Vertex const& vertex) const {// 使用 Boost 风格的哈希组合size_t seed = 0;auto hashCombine = [&seed](auto v) {seed ^= std::hash<decltype(v)>()(v) + 0x9e3779b9 + (seed << 6) + (seed >> 2);};hashCombine(vertex.pos);hashCombine(vertex.color);hashCombine(vertex.texCoord);return seed;}};
}
3. MeshInstance 封装
#pragma once
#include <glm/glm.hpp>
#include <vector>
#include "renderer/VkTypes.h"using namespace renderer;namespace scene {// 表示场景中的一个网格实例,包含几何数据和渲染状态struct MeshInstance {// 存储网格的顶点数据,每个顶点包含位置、法线、纹理坐标等信息std::vector<Vertex> vertices;// 存储顶点索引,用于索引绘制std::vector<uint32_t> indices;// 存储与网格关联的纹理,VkTexture 可能是自定义的纹理包装结构std::vector<VkTexture> textures;// 模型矩阵,用于将网格从局部空间变换到世界空间glm::mat4 modelMatrix;// 以下是 Vulkan 相关的句柄,用于 GPU 资源访问// 顶点缓冲区句柄,存储顶点数据在 GPU 内存中的位置VkBuffer vertexBuffer = VK_NULL_HANDLE;// 索引缓冲区句柄,存储索引数据在 GPU 内存中的位置VkBuffer indexBuffer = VK_NULL_HANDLE;// 顶点缓冲区对应的设备内存句柄VkDeviceMemory vertexMemory = VK_NULL_HANDLE;// 索引缓冲区对应的设备内存句柄VkDeviceMemory indexMemory = VK_NULL_HANDLE;// 带参数的构造函数,使用提供的顶点、索引和模型矩阵初始化网格实例MeshInstance(const std::vector<Vertex>& v,const std::vector<uint32_t>& i,const glm::mat4& matrix = glm::mat4(1.0f)): vertices(v), indices(i), modelMatrix(matrix) {}// 默认构造函数,创建一个单位矩阵的空网格实例MeshInstance():modelMatrix(glm::mat4(1.0f)){}};
} // namespace scene
代码功能概述
这段代码定义了一个名为 MeshInstance
的结构体,用于表示 3D 场景中的一个可渲染网格对象。它包含以下核心功能:
- 几何数据存储:使用
vertices
和indices
存储网格的几何形状 - 纹理管理:通过
textures
向量管理与网格关联的纹理 - 空间变换:使用
modelMatrix
控制网格在场景中的位置、旋转和缩放 - Vulkan 资源:包含用于渲染的 GPU 缓冲区和内存句柄
- 构造函数:提供灵活的初始化方式,支持自定义或默认参数
设计思路
- 数据与渲染分离:将 CPU 端的几何数据(vertices, indices)与 GPU 端的资源(vertexBuffer, indexBuffer)分开存储
- 灵活的实例化:通过 modelMatrix 支持同一个网格模型的多次实例化,节省内存
- 资源管理:使用 RAII 原则之外的方式管理 Vulkan 资源(需手动释放这些句柄)
潜在问题与注意事项
- 资源泄漏风险:Vulkan 句柄需要手动释放,建议在析构函数或单独的清理函数中添加释放代码
- 线程安全:在多线程环境中使用这些资源时需要额外同步
- 纹理处理:VkTexture 类型需要确保正确管理纹理加载和 Vulkan 图像资源
- 内存对齐:在创建 GPU 缓冲区时需要考虑 Vulkan 的内存对齐要求
4. 更新 VkContext 对象
namespace renderer {...struct VkContext {GLFWwindow* window;VkInstance instance;VkDebugUtilsMessengerEXT debugMessenger;VkPhysicalDevice physicalDevice = VK_NULL_HANDLE;VkDevice device;QueueFamilyIndices queueFamilyIndices;VkQueue graphicsQueue;VkQueue presentQueue;uint32_t currentFrame = 0;VkSurfaceKHR surface;VkSwapchainKHR swapChain;std::vector<VkImage> swapChainImages;VkFormat swapChainImageFormat;VkExtent2D swapChainExtent;std::vector<VkImageView> swapChainImageViews;VkRenderPass renderPass;VkDescriptorSetLayout descriptorSetLayout;VkPipelineLayout pipelineLayout;VkPipeline graphicsPipeline;std::vector<VkFramebuffer> swapChainFramebuffers;VkCommandPool commandPool;std::vector<VkCommandBuffer> commandBuffers;std::vector<VkSemaphore> imageAvailableSemaphores;std::vector<VkSemaphore> renderFinishedSemaphores;std::vector<VkFence> inFlightFences;std::vector<VkBuffer> uniformBuffers;std::vector<VkDeviceMemory> uniformBuffersMemory;std::vector<void*> uniformBuffersMapped;UBO* ubo;VkDescriptorPool descriptorPool;std::vector<std::vector<VkDescriptorSet>> descriptorSets; // 存放每个网格实例每个并行帧绑定的描述符集VkImage depthImage;VkDeviceMemory depthImageMemory;VkImageView depthImageView;std::vector<scene::MeshInstance*> meshInstances; // 所有要渲染的网格实例scene::Camera* camera;bool framebufferResized = false;};}; // namespace renderer
5. 修改 vkInit 纹理创建代码块
// 创建纹理: 每个MeshInstance可能有多个纹理for (scene::MeshInstance* mesh : ctx->meshInstances) {std::vector<VkTexture>& textures = mesh->textures;for (VkTexture& texture : textures) {vkCreateTexture(ctx, texture);}}...// 对于没有纹理的 Mesh,我会给它生成一个透明的默认纹理,这样着色器中就不用判断// 当前渲染对象有没有纹理,统一标准易维护。void vkCreateTexture(VkContext* ctx, VkTexture& texture) {// 1. 加载纹理int texWidth, texHeight, texChannels;stbi_uc* pixels = nullptr;if (texture.pixels.size()) {pixels = reinterpret_cast<stbi_uc*>(texture.pixels.data());texWidth = texture.width;texHeight = texture.height;texChannels = 4;} else if (texture.assetId == "") { // 透明纹理static uint8_t opacityPixel[4] = {255, 255, 255, 0};pixels = opacityPixel;texWidth = 1;texHeight = 1;texChannels = 4;} else {pixels = stbi_load(texture.assetId.c_str(), &texWidth, &texHeight, &texChannels, STBI_rgb_alpha);}if (!pixels) {throw std::runtime_error("加载纹理图片失败!");}...}
6. 修改 vkInit 顶点缓冲区初始化代码块
// 遍历场景中的所有网格实例,为每个网格创建顶点缓冲区
for (scene::MeshInstance* mesh : ctx->meshInstances) {// 跳过没有顶点数据的网格if (mesh->vertices.empty()) continue;// 计算顶点缓冲区的大小(以字节为单位)size_t vertexBufferSize = mesh->vertices.size() * sizeof(Vertex);// 临时缓冲区(暂存区),用于在 CPU 和 GPU 之间传输数据VkBuffer stagingBuffer;VkDeviceMemory stagingBufferMemory;// 第一步:创建暂存缓冲区// 这个缓冲区在 CPU 可见的内存中,我们可以向其中写入数据createBuffer(ctx->physicalDevice,ctx->device,vertexBufferSize,VK_BUFFER_USAGE_TRANSFER_SRC_BIT, // 用作传输源VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | VK_MEMORY_PROPERTY_HOST_COHERENT_BIT,stagingBuffer,stagingBufferMemory);// 把顶点数据从 CPU 内存复制到暂存缓冲区void* data;vkMapMemory(ctx->device, stagingBufferMemory, 0, vertexBufferSize, 0, &data);memcpy(data, mesh->vertices.data(), vertexBufferSize);vkUnmapMemory(ctx->device, stagingBufferMemory);// 第二步:创建设备本地顶点缓冲区// 这个缓冲区位于 GPU 内存中,适合高性能访问createBuffer(ctx->physicalDevice,ctx->device,vertexBufferSize,VK_BUFFER_USAGE_TRANSFER_DST_BIT | VK_BUFFER_USAGE_VERTEX_BUFFER_BIT,VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT, // 设备本地内存(GPU 专用)mesh->vertexBuffer,mesh->vertexMemory);// 第三步:把数据从暂存缓冲区复制到设备本地缓冲区copyBuffer(stagingBuffer, mesh->vertexBuffer, vertexBufferSize, ctx);// 第四步:清理暂存资源// 数据传输完成后,暂存缓冲区就不再需要了vkDestroyBuffer(ctx->device, stagingBuffer, nullptr);vkFreeMemory(ctx->device, stagingBufferMemory, nullptr);
}
7. 修改 vkInit 索引缓冲区初始化代码块
// 遍历场景中的所有网格实例,为每个网格创建索引缓冲区
for (scene::MeshInstance* mesh : ctx->meshInstances) {// 跳过没有索引数据的网格if (mesh->indices.empty()) continue;// 计算索引缓冲区的大小(以字节为单位)size_t indexBufferSize = mesh->indices.size() * sizeof(mesh->indices[0]);// 临时缓冲区(暂存区),用于在 CPU 和 GPU 之间传输数据VkBuffer stagingBuffer;VkDeviceMemory stagingBufferMemory;// 第一步:创建暂存缓冲区// 这个缓冲区在 CPU 可见的内存中,我们可以向其中写入数据createBuffer(ctx->physicalDevice,ctx->device,indexBufferSize,VK_BUFFER_USAGE_TRANSFER_SRC_BIT, // 用作传输源VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | VK_MEMORY_PROPERTY_HOST_COHERENT_BIT,stagingBuffer,stagingBufferMemory);// 把索引数据从 CPU 内存复制到暂存缓冲区void* data;vkMapMemory(ctx->device, stagingBufferMemory, 0, indexBufferSize, 0, &data);memcpy(data, mesh->indices.data(), indexBufferSize);vkUnmapMemory(ctx->device, stagingBufferMemory);// 第二步:创建设备本地索引缓冲区// 这个缓冲区位于 GPU 内存中,适合高性能访问createBuffer(ctx->physicalDevice,ctx->device,indexBufferSize,VK_BUFFER_USAGE_TRANSFER_DST_BIT | VK_BUFFER_USAGE_INDEX_BUFFER_BIT,VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT, // 设备本地内存(GPU 专用)mesh->indexBuffer,mesh->indexMemory);// 第三步:把数据从暂存缓冲区复制到设备本地缓冲区copyBuffer(stagingBuffer, mesh->indexBuffer, indexBufferSize, ctx);// 第四步:清理暂存资源// 数据传输完成后,暂存缓冲区就不再需要了vkDestroyBuffer(ctx->device, stagingBuffer, nullptr);vkFreeMemory(ctx->device, stagingBufferMemory, nullptr);
}
8. 修改 vkInit 描述符池容量初始化代码块
// 创建描述符池
{// 定义描述符池支持的描述符类型和数量std::array<VkDescriptorPoolSize, 2> poolSizes{};// 获取场景中网格实例的数量size_t meshCount = ctx->meshInstances.size();// 第一种类型:统一缓冲区描述符(用于 MVP 矩阵等数据)poolSizes[0].type = VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER;// 每个网格在每一帧都需要一个统一缓冲区描述符poolSizes[0].descriptorCount = static_cast<uint32_t>(MAX_CONCURRENT_FRAMES * meshCount);// 第二种类型:组合图像采样器描述符(用于纹理)poolSizes[1].type = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER;// 每个网格在每一帧都需要一个图像采样器描述符poolSizes[1].descriptorCount = static_cast<uint32_t>(MAX_CONCURRENT_FRAMES * meshCount);// 描述符池创建信息VkDescriptorPoolCreateInfo poolInfo{