基于 vkguide.dev 教程构建的 Vulkan 1.3 实时渲染引擎,实现了从底层初始化到 GLTF 场景加载的完整渲染管线。支持 PBR Metallic-Roughness 材质模型、层次化场景树、计算着色器背景、ImGui 调试界面等特性。
| 类别 | 技术 |
|---|---|
| 图形 API | Vulkan 1.3(Dynamic Rendering, Synchronization2, Buffer Device Address) |
| 窗口系统 | SDL2 |
| 内存分配 | Vulkan Memory Allocator (VMA) |
| 场景格式 | glTF 2.0(通过 fastgltf 解析) |
| 数学库 | GLM |
| 图像解码 | stb_image |
| 调试 UI | Dear ImGui (Vulkan + SDL2 backend) |
| 构建系统 | CMake + glslangValidator (SPIR-V 编译) |
| C++ 标准 | C++20 |
├── CMakeLists.txt # 根构建脚本,含 GLSL→SPIR-V 编译规则
├── src/
│ ├── vk_engine.h/cpp # 引擎核心:初始化、渲染循环、资源管理
│ ├── vk_types.h # 基础类型:AllocatedImage/Buffer、Vertex、材质、场景节点
│ ├── vk_loader.h/cpp # GLTF 场景加载器(Visitor 模式解析)
│ ├── vk_descriptors.h/cpp # 描述符集布局构建、可增长池分配器、DescriptorWriter
│ ├── vk_pipelines.h/cpp # 图形管线 Builder 模式封装、着色器模块加载
│ ├── vk_images.h/cpp # 图像布局转换、Blit 复制工具
│ ├── vk_initializers.h/cpp # Vulkan 结构体创建辅助函数集
│ ├── camera.h/cpp # FPS 风格相机(键鼠控制、View 矩阵生成)
│ └── main.cpp # 入口点
├── shaders/
│ ├── input_structures.glsl # 共享 Uniform/Sampler 布局声明
│ ├── mesh.vert # PBR 网格顶点着色器(Buffer Reference + Push Constants)
│ ├── mesh.frag # PBR 网格片段着色器(Blinn-Phong 光照)
│ ├── gradient_color.comp # 渐变背景计算着色器
│ └── sky.comp # 天空背景计算着色器
├── assets/ # GLTF 模型资源(structure.glb, basicmesh.glb)
└── third_party/ # SDL2, GLM, VMA, fastgltf, fmt, imgui, vkbootstrap
init()
├─ init_vulkan() 创建 Instance → Validation Layer → Surface → 物理/逻辑设备 → Queue → VMA
├─ init_swapchain() 创建交换链 + 高精度 DrawImage (R16G16B16A16_SFLOAT) + DepthImage
├─ init_commands() 每帧 CommandPool/Buffer + Immediate Submit 专用 Pool
├─ init_sync_structures() 每帧 Fence + Swapchain/Render Semaphore
├─ init_descriptors() 全局/每帧 Descriptor Pool + 三种 SetLayout
├─ init_pipelines() 计算管线(背景)+ PBR 图形管线(Opaque/Transparent)
├─ init_imgui() ImGui Vulkan Backend(Dynamic Rendering)
└─ init_default_data() 默认纹理/采样器 + GLTF 加载 + 默认材质实例
本引擎将渲染数据解耦为三个层次:
MaterialPipeline (管线层) MaterialInstance (材质实例层)
┌──────────────────────┐ ┌────────────────────────────┐
│ VkPipeline pipeline │◄─────────│ MaterialPipeline* pipeline │
│ VkPipelineLayout │ │ VkDescriptorSet materialSet│
└──────────────────────┘ │ MaterialPass passType │
多个 MaterialInstance 可 └────────────────────────────┘
共享同一条 Pipeline ▲
│ 引用
MeshAsset (网格资产层) RenderObject (渲染对象层)
┌─────────────────────────┐ ┌────────────────────────────────┐
│ string name │ │ uint32_t indexCount, firstIndex│
│ vector<GeoSurface> │─────►│ VkBuffer indexBuffer │
│ └ startIndex, count │ │ MaterialInstance* material │
│ └ shared_ptr<Material>│ │ mat4 transform │
│ GPUMeshBuffers │─────►│ VkDeviceAddress vertexBuffer │
│ └ indexBuffer │ └────────────────────────────────┘
│ └ vertexBuffer │ 每帧由 MeshNode::Draw() 生成
│ └ vertexBufferAddress │
└─────────────────────────┘
设计要点:
- MaterialPipeline 仅存储
VkPipeline和VkPipelineLayout,生命周期由GLTFMetallic_Roughness统一管理,不由单个材质负责销毁——因为材质实例数量可能很多,在每个实例上做引用计数开销不合理。 - MaterialInstance 持有指向 Pipeline 的裸指针和自身的
VkDescriptorSet(包含 Uniform Buffer、颜色贴图、金属度-粗糙度贴图)。按MaterialPass(Opaque / Transparent)区分渲染通道。 - RenderObject 是每帧临时生成的绘制命令数据包,将几何数据(index/vertex)、材质、变换矩阵打包在一起,集中存入
DrawContext以便批量绘制。
IRenderable (纯虚基类)
└── Node (场景节点)
├── weak_ptr<Node> parent // 弱引用避免循环依赖
├── vector<shared_ptr<Node>> children
├── localTransform / worldTransform
├── refreshTransform(parentMatrix) // 自顶向下递归传播变换
└── virtual Draw(topMatrix, ctx) // 递归遍历所有子节点
└── MeshNode : Node
├── shared_ptr<MeshAsset> mesh
└── Draw() 重写:
1. nodeMatrix = topMatrix × worldTransform
2. 将 mesh 的每个 surface 封装为 RenderObject 加入 DrawContext
3. 调用 Node::Draw() 继续递归子节点
设计要点:
- 利用
IRenderable多态接口实现统一渲染遍历——不管节点是否含有网格,都通过Draw()虚函数递归。 Node::parent使用weak_ptr避免 parent↔children 的循环强引用导致内存泄漏。refreshTransform()在场景加载后调用一次,从根节点向下递归计算worldTransform = parentMatrix × localTransform。- 实际渲染时
Draw()传入topMatrix参数,与worldTransform相乘得到最终的模型矩阵,不会修改场景树本身的数据——确保遍历的无副作用性。
loadGltf() 函数完成从文件解析到 GPU 资源创建的全流程:
loadGltf(engine, filePath)
├─ fastgltf::Parser 解析 .gltf / .glb
├─ 加载纹理 ─── std::visit(fastgltf::visitor{...}, image.data)
│ ├─ URI 路径 → stbi_load() → engine->create_image()
│ ├─ 内嵌 Vector → stbi_load_from_memory() → engine->create_image()
│ └─ BufferView → 二次 visit 取出数据 → engine->create_image()
├─ 加载材质 ─── 读取 PBR 参数 → write_material() 生成 MaterialInstance
├─ 加载网格 ─── iterateAccessor 读取 index/position/normal/uv/color → uploadMesh()
├─ 构建节点 ─── std::visit(fastgltf::visitor{...}, node.transform)
│ ├─ TransformMatrix → 直接 memcpy
│ └─ TRS → glm::translate × glm::toMat4(quat) × glm::scale
├─ 建立父子关系 → children.push_back / parent 赋值
└─ 找到顶层节点 → refreshTransform(I)
Visitor 模式的应用:fastgltf 使用 std::variant 表示数据源(URI / 内嵌向量 / BufferView)和变换类型(矩阵 / TRS)。通过 std::visit + fastgltf::visitor(本质是 overloaded lambda 集合),针对每种变体类型提供不同的处理逻辑,在编译期进行类型安全的分派,避免了运行时的 dynamic_cast 或类型标签判断。
GPU 端着色器(mesh.vert):
┌─────────────────────────────────────────────┐
│ layout(push_constant) uniform constants { │
│ mat4 render_matrix; ← 模型矩阵 │
│ VertexBuffer vertexBuffer; ← 64位GPU地址 │
│ } │
│ │
│ layout(buffer_reference) readonly buffer │
│ VertexBuffer { Vertex vertices[]; }; │
│ │
│ Vertex v = vertexBuffer.vertices[gl_VertexIndex]; │
└─────────────────────────────────────────────┘
CPU 端每个 DrawCall:
vkCmdPushConstants(cmd, layout, VERTEX_BIT, 0, sizeof(GPUDrawPushConstants), &push);
└── push.worldMatrix = nodeMatrix (glm::mat4, 64 bytes)
push.vertexBuffer = bufferAddress (VkDeviceAddress, 8 bytes)
设计要点:
- 顶点数据不通过传统的 Vertex Input Binding 传递,而是以 Storage Buffer 形式上传到 GPU,通过
VkDeviceAddress(Buffer Device Address 特性)获取其 64 位 GPU 虚拟地址,以 Push Constants 的形式传给着色器。 - 着色器中使用
GL_EXT_buffer_reference扩展将该地址解引用为结构化的VertexBuffer,直接用gl_VertexIndex索引获取顶点——无需 VkVertexInputBindingDescription,管线配置更简洁。 - Push Constants 仅 72 字节(mat4 + uint64),远低于 Vulkan 规范保证的最小 128 字节上限,每次
vkCmdDraw前更新,延迟极低。
DescriptorLayoutBuilder DescriptorAllocatorGrowable DescriptorWriter
┌─────────────────────┐ ┌──────────────────────────┐ ┌─────────────────┐
│ add_binding(bindIdx, │ │ readyPools: 可用池列表 │ │ write_image() │
│ descriptorType) │ │ fullPools: 满载池列表 │ │ write_buffer() │
│ build(device,stage) │ │ setsPerPool (×1.5 增长) │ │ update_set() │
└─────────────────────┘ │ │ └─────────────────┘
│ allocate(device, layout): │
│ 1. get_pool (ready→取出) │
│ 2. 分配失败→移入fullPools│
│ 3. 创建新pool重试 │
│ 4. 成功→放回readyPools │
└──────────────────────────┘
三种 DescriptorSetLayout:
| Layout | Binding | 类型 | 用途 |
|---|---|---|---|
drawImageDescriptorLayout |
0: STORAGE_IMAGE | Compute | 背景计算着色器写入 drawImage |
gpuSceneDataDescriptorLayout |
0: UNIFORM_BUFFER | Vert+Frag | 场景全局数据 (VP矩阵、光照) |
materialLayout |
0: UNIFORM_BUFFER, 1: IMAGE_SAMPLER, 2: IMAGE_SAMPLER | Vert+Frag | 材质常量 + 颜色贴图 + 金属度粗糙度贴图 |
全局资源使用全局 DescriptorAllocatorGrowable;每帧临时资源(如场景 Uniform Buffer)使用 FrameData._frameDescriptors,每帧 clear_pools() 重置后重新分配。
draw() 每帧执行:
├─ update_scene()
│ ├─ 更新相机 → 计算 View/Projection 矩阵
│ ├─ 清空 DrawContext
│ └─ 对场景树调用 Draw() → 填充 OpaqueSurfaces / TransparentSurfaces
│
├─ vkWaitForFences (等待本帧 GPU 完成)
├─ 帧级 DeletionQueue.flush() + frameDescriptors.clear_pools()
├─ vkResetFences
├─ vkAcquireNextImageKHR (获取交换链图像索引, swapchainSemaphore 同步)
│
├─ 录制 Command Buffer:
│ ├─ transition drawImage → GENERAL
│ ├─ draw_background():Compute Dispatch (天空/渐变)
│ ├─ transition drawImage → COLOR_ATTACHMENT, depthImage → DEPTH_ATTACHMENT
│ ├─ draw_geometry():
│ │ ├─ 创建帧级 Uniform Buffer → 写入 GPUSceneData → 绑定描述符集
│ │ ├─ vkCmdBeginRendering (Dynamic Rendering, 无 RenderPass)
│ │ ├─ 遍历 OpaqueSurfaces → 绑定管线/描述符/IndexBuffer → PushConstants → DrawIndexed
│ │ ├─ 遍历 TransparentSurfaces → 同上
│ │ └─ vkCmdEndRendering
│ ├─ transition drawImage → TRANSFER_SRC, swapchainImage → TRANSFER_DST
│ ├─ vkCmdBlitImage2 (drawImage 拉伸复制到 swapchainImage)
│ ├─ transition swapchainImage → COLOR_ATTACHMENT
│ ├─ draw_imgui()
│ └─ transition swapchainImage → PRESENT_SRC
│
├─ vkQueueSubmit2 (renderFence 同步, swapchainSemaphore 等待, renderSemaphore 信号)
└─ vkQueuePresentKHR (renderSemaphore 等待)
同步策略:
| 同步原语 | 类型 | 作用 |
|---|---|---|
renderFence |
CPU↔GPU | CPU 等待上一帧绘制完成后才重用该帧的 CommandBuffer 和临时资源 |
swapchainSemaphore |
GPU↔GPU | 确保交换链图像获取完毕后才开始渲染 |
renderSemaphore |
GPU↔GPU | 确保渲染完毕后才提交到显示 |
VkImageMemoryBarrier2 |
GPU 内部 | 图像布局转换时的管线阶段与内存访问同步 |
struct DeletionQueue {
std::deque<std::function<void()>> deletors;
void push_function(std::function<void()>&& func);
void flush(); // 逆序执行所有销毁函数,保证依赖正确性
};引擎使用两级 DeletionQueue:
_mainDeletionQueue(全局级):注册 VMA Allocator、Descriptor Pool/Layout、Pipeline、默认纹理/采样器、Immediate Submit Pool 等生命周期与引擎等长的资源。在cleanup()中 flush。FrameData._deletionQueue(帧级):注册每帧临时创建的 Uniform Buffer 等资源。在下一次使用该帧槽位时 flush。
加上 LoadedGLTF 析构函数构成第三级——GLTF 场景拥有自己的描述符池、材质缓冲区、网格缓冲区和纹理,在 ~LoadedGLTF() 中通过 clearAll() 统一释放。由于场景存储为 shared_ptr<LoadedGLTF>,当从 loadedScenes 中移除时自动触发析构。
为什么使用逆序 flush:Vulkan 资源之间存在依赖关系(例如 ImageView 依赖 Image,DescriptorSet 依赖 DescriptorPool),后创建的资源通常依赖先创建的资源,因此必须先销毁后创建的(逆序)才能避免使用已释放资源的 Validation Error。
CPU 帧 N: [录制 CmdBuf N] ──submit──> [GPU 执行帧 N]
CPU 帧 N+1: [录制 CmdBuf N+1] ─submit─> [GPU 执行帧 N+1]
↑ ↑
使用 _frames[1] 使用 _frames[0](fence 保护)
设置 FRAME_OVERLAP = 2,CPU 端维护 2 套 FrameData(CommandPool/Buffer、Fence、Semaphore、帧级 DeletionQueue/DescriptorPool),实现经典的乒乓缓冲。CPU 在录制帧 N+1 时 GPU 可以并行执行帧 N,通过 Fence 保证不会覆写正在执行的帧资源。
- Dynamic Rendering (
VK_KHR_dynamic_rendering):不再创建VkRenderPass和VkFramebuffer,改用vkCmdBeginRendering/vkCmdEndRendering,通过VkRenderingInfo直接指定颜色/深度附件,管线通过VkPipelineRenderingCreateInfo链接到 pNext。减少了大量模板对象管理。 - Synchronization2 (
VK_KHR_synchronization2):使用vkQueueSubmit2、VkImageMemoryBarrier2、vkCmdPipelineBarrier2等新 API,提供更精细的管线阶段 (VK_PIPELINE_STAGE_2_*) 和内存访问 (VK_ACCESS_2_*) 控制。 - Buffer Device Address (
VK_KHR_buffer_device_address):为顶点缓冲区启用 GPU 虚拟地址访问(VK_BUFFER_USAGE_SHADER_DEVICE_ADDRESS_BIT),配合着色器buffer_reference扩展实现无绑定 (Bindless) 风格的顶点数据访问。
# 前提:安装 Vulkan SDK(含 glslangValidator)
cmake -B build -S .
cmake --build build --config Release
# SPIR-V 着色器编译
cmake --build build --target Shaders
# 运行(确保 assets/ 中有 structure.glb)
./bin/engine- WASD — 相机移动
- 鼠标右键拖动 — 相机旋转
- ImGui 面板 — 调节渲染缩放、切换背景效果、修改着色器参数