这个系列文章我们来介绍一位海外工程师如何探索 Vulkan 音视频技术,对于想要开始学习音视频技术的朋友,这些文章是份不错的入门资料,本篇介绍 Vulkan 基础渲染示例。
——来自公众号“关键帧Keyframe”的分享
在 SaschaWillems/Vulkan 仓库中,基础渲染示例展示了 Vulkan 图形编程的基本概念。这些示例为理解 Vulkan 的工作原理提供了坚实的基础,并作为更复杂渲染技术的构建模块。本指南将带你了解关键的基础渲染示例,并解释它们展示的核心概念。
三角形示例(examples/triangle/triangle.cpp)是 Vulkan 图形编程的”Hello World”。它演示了将简单彩色三角形渲染到屏幕所需的最小设置,是学习 Vulkan 的完美起点。
1、展示的核心组件
三角形示例展示了渲染开始之前必须创建的所有基本 Vulkan 对象:
- 实例和设备:连接应用程序与 Vulkan 驱动程序并选择物理设备的基础对象
- 交换链:管理渲染图像到屏幕的呈现
- 渲染通道:定义渲染期间使用的附件(颜色、深度)
- 管线布局:描述着色器与描述符集之间的接口
- 图形管线:包含渲染的所有固定功能状态
- 顶点和索引缓冲区:存储几何数据
- 统一缓冲区:将变换矩阵传递给着色器
- 描述符集:将着色器资源连接到管线
- 命令缓冲区:记录要执行的渲染命令
- 同步原语:确保操作的正确顺序
2、展示的关键 Vulkan 概念
三角形示例介绍了一些与传统图形 API 不同的关键 Vulkan 概念:
2.1、 显式内存管理
与 OpenGL 不同,Vulkan 需要显式管理 GPU 内存。该示例演示了暂存缓冲区模式,其中数据首先上传到主机可见缓冲区,然后复制到设备本地缓冲区以获得最佳 GPU 访问:
// 创建主机可见的暂存缓冲区
VkBufferCreateInfo stagingBufferInfo{};
stagingBufferInfo.usage = VK_BUFFER_USAGE_TRANSFER_SRC_BIT;
VK_CHECK_RESULT(vkCreateBuffer(device, &stagingBufferInfo, nullptr, &stagingBuffer.buffer));
// 创建设备本地的目标缓冲区
VkBufferCreateInfo deviceBufferInfo{};
deviceBufferInfo.usage = VK_BUFFER_USAGE_VERTEX_BUFFER_BIT | VK_BUFFER_USAGE_TRANSFER_DST_BIT;
VK_CHECK_RESULT(vkCreateBuffer(device, &deviceBufferInfo, nullptr, &vertices.buffer));
// 从暂存缓冲区复制到设备缓冲区
VkBufferCopy copyRegion{};
copyRegion.size = vertexBufferSize;
vkCmdCopyBuffer(copyCmd, stagingBuffer.buffer, vertices.buffer, 1, ©Region);
来源:examples/triangle/triangle.cpp#L271-298
2.2、同步和帧节奏
Vulkan 需要 CPU 和 GPU 之间的显式同步。该示例使用信号量进行队列间同步,使用栅栏进行 CPU 到 GPU 的同步:
// 用于 CPU-GPU 同步的栅栏
std::array<VkFence, MAX_CONCURRENT_FRAMES> waitFences{};
// 用于队列间同步的信号量
std::vector<VkSemaphore> presentCompleteSemaphores{};
std::vector<VkSemaphore> renderCompleteSemaphores{};
来源:examples/triangle/triangle.cpp#L105-111
该示例实现了帧节奏,其中有多个帧在飞行中(通常为 2 个),允许 CPU 在 GPU 仍在处理当前帧时准备下一帧:
#define MAX_CONCURRENT_FRAMES 2
uint32_t currentFrame{ 0 };
// 在渲染循环中:
currentFrame = (currentFrame + 1) % MAX_CONCURRENT_FRAMES;
来源:examples/triangle/triangle.cpp#L33, examples/triangle/triangle.cpp#L1045-1046
2.3、管线状态对象
Vulkan 使用管线状态对象(PSO),这些对象封装了几乎所有的渲染状态。与 OpenGL 的动态状态机不同,Vulkan 要求你预先定义完整的管线状态:
VkGraphicsPipelineCreateInfo pipelineCI{};
pipelineCI.layout = pipelineLayout;
pipelineCI.renderPass = renderPass;
pipelineCI.pVertexInputState = &vertexInputStateCI;
pipelineCI.pInputAssemblyState = &inputAssemblyStateCI;
pipelineCI.pRasterizationState = &rasterizationStateCI;
// ... 以及更多状态对象
VK_CHECK_RESULT(vkCreateGraphicsPipelines(device, pipelineCache, 1, &pipelineCI, nullptr, &pipeline));
来源:examples/triangle/triangle.cpp#L697-847
3、纹理映射示例
纹理示例(examples/texture/texture.cpp)在三角形示例建立的基础上,演示了如何加载纹理并将其应用于 3D 对象。这是朝着创建更具视觉趣味的场景迈出的关键一步。
3.1、展示的关键概念
3.1.1、图像创建和内存管理
Vulkan 中的纹理表示为 VkImage 对象,这些对象需要与缓冲区类似的仔细内存管理:
// 创建纹理图像
VkImageCreateInfo imageInfo{};
imageInfo.sType = VK_STRUCTURE_TYPE_IMAGE_CREATE_INFO;
imageInfo.imageType = VK_IMAGE_TYPE_2D;
imageInfo.format = VK_FORMAT_R8G8B8A8_UNORM;
imageInfo.extent = { texWidth, texHeight, 1 };
imageInfo.mipLevels = 1;
imageInfo.arrayLayers = 1;
imageInfo.samples = VK_SAMPLE_COUNT_1_BIT;
imageInfo.tiling = VK_IMAGE_TILING_OPTIMAL;
imageInfo.usage = VK_IMAGE_USAGE_SAMPLED_BIT;
imageInfo.initialLayout = VK_IMAGE_LAYOUT_UNDEFINED;
VK_CHECK_RESULT(vkCreateImage(device, &imageInfo, nullptr, &texture.image));
3.1.2、图像视图和采样器
Vulkan 将纹理数据(图像)与访问方式(图像视图)和采样方式(采样器)分开:
// 创建图像视图
VkImageViewCreateInfo viewInfo{};
viewInfo.sType = VK_STRUCTURE_TYPE_IMAGE_VIEW_CREATE_INFO;
viewInfo.image = texture.image;
viewInfo.viewType = VK_IMAGE_VIEW_TYPE_2D;
viewInfo.format = VK_FORMAT_R8G8B8A8_UNORM;
VK_CHECK_RESULT(vkCreateImageView(device, &viewInfo, nullptr, &texture.view));
// 创建采样器
VkSamplerCreateInfo samplerInfo{};
samplerInfo.sType = VK_STRUCTURE_TYPE_SAMPLER_CREATE_INFO;
samplerInfo.magFilter = VK_FILTER_LINEAR;
samplerInfo.minFilter = VK_FILTER_LINEAR;
samplerInfo.addressModeU = VK_SAMPLER_ADDRESS_MODE_REPEAT;
samplerInfo.addressModeV = VK_SAMPLER_ADDRESS_MODE_REPEAT;
samplerInfo.addressModeW = VK_SAMPLER_ADDRESS_MODE_REPEAT;
VK_CHECK_RESULT(vkCreateSampler(device, &samplerInfo, nullptr, &texture.sampler));
3.1.3、纹理描述符集
纹理通过描述符集访问,这些描述符集需要更新描述符集布局和池:
// 向描述符集布局添加组合图像采样器
VkDescriptorSetLayoutBinding samplerLayoutBinding{};
samplerLayoutBinding.descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER;
samplerLayoutBinding.descriptorCount = 1;
samplerLayoutBinding.stageFlags = VK_SHADER_STAGE_FRAGMENT_BIT;
// 使用纹理更新描述符集
VkDescriptorImageInfo imageInfo{};
imageInfo.imageLayout = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL;
imageInfo.imageView = texture.view;
imageInfo.sampler = texture.sampler;
VkWriteDescriptorSet descriptorWrite{};
descriptorWrite.sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET;
descriptorWrite.dstSet = descriptorSets[i];
descriptorWrite.dstBinding = 1;
descriptorWrite.descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER;
descriptorWrite.descriptorCount = 1;
descriptorWrite.pImageInfo = &imageInfo;
vkUpdateDescriptorSets(device, 1, &descriptorWrite, 0, nullptr);
4、其他基础渲染示例
该仓库还包括其他几个在这些基本概念基础上构建的示例:
4.1、顶点属性示例
vertexattributes 示例演示了不同的顶点属性配置,展示了如何将各种类型的数据(位置、法线、纹理坐标、颜色)传递给顶点着色器。它说明了:
- 不同的顶点属性格式(float、vec2、vec3、vec4)
- 顶点属性偏移和步幅
- 绑定多个顶点缓冲区
4.2、动态状态示例
dynamicstate 示例展示了如何使用 Vulkan 的动态状态功能来更改某些管线状态,而无需重新创建整个管线。这包括:
- 动态视口和裁剪
- 动态线宽
- 动态混合常量
- 动态深度偏差
4.3、多重采样示例
multisampling 示例演示了使用多重采样的抗锯齿技术,这是减少渲染图像中锯齿边缘的常见方法。它涵盖了:
- 创建多重采样图像
- 设置多重采样渲染通道
- 将多重采样图像解析为单采样以进行呈现
5、基础渲染的学习路径
对于首次接触 Vulkan 的初学者,我们建议通过基础渲染示例采用以下学习路径:
- 三角形示例 – 从这里开始了解 Vulkan 设置和渲染的绝对基础知识
- 纹理示例 – 学习如何应用纹理以使对象在视觉上更有趣
- 顶点属性示例 – 了解如何将不同类型的顶点数据传递给着色器
- 动态状态示例 – 学习如何有效地更改某些渲染状态
- 多重采样示例 – 探索抗锯齿技术以获得更好的视觉质量
每个示例都建立在前一个示例引入的概念之上,创建了结构化学习路径,逐步引入 Vulkan 的复杂性。
6、基础渲染示例中的常见模式
本仓库中的所有基础渲染示例都遵循反映 Vulkan 开发最佳实践的类似模式:
6.1、初始化模式
所有示例都遵循一致的初始化模式:
void prepare() {
// 1. 创建同步原语
createSynchronizationPrimitives();
// 2. 设置命令池和缓冲区
createCommandBuffers();
// 3. 创建顶点和索引缓冲区
createVertexBuffer();
// 4. 设置统一缓冲区
createUniformBuffers();
// 5. 创建描述符集布局和池
createDescriptorSetLayout();
createDescriptorPool();
// 6. 创建描述符集
createDescriptorSets();
// 7. 创建图形管线
createPipelines();
}
6.2、渲染循环模式
渲染循环在所有示例中遵循一致的模式:
void render() {
// 1. 等待前一帧完成
vkWaitForFences(device, 1, &waitFences[currentFrame], VK_TRUE, UINT64_MAX);
// 2. 从交换链获取下一帧图像
vkAcquireNextImageKHR(device, swapChain.swapChain, UINT64_MAX,
presentCompleteSemaphores[currentFrame], VK_NULL_HANDLE, &imageIndex);
// 3. 更新统一缓冲区
updateUniformBuffers();
// 4. 记录命令缓冲区
recordCommandBuffer();
// 5. 提交命令缓冲区
vkQueueSubmit(queue, 1, &submitInfo, waitFences[currentFrame]);
// 6. 呈现图像
vkQueuePresentKHR(queue, &presentInfo);
// 7. 进入下一帧
currentFrame = (currentFrame + 1) % MAX_CONCURRENT_FRAMES;
}
6.3、清理模式
所有示例都遵循 RAII 原则,在析构函数中进行正确清理:
~VulkanExample() {
if (device) {
// 销毁管线对象
vkDestroyPipeline(device, pipeline, nullptr);
vkDestroyPipelineLayout(device, pipelineLayout, nullptr);
// 销毁描述符对象
vkDestroyDescriptorSetLayout(device, descriptorSetLayout, nullptr);
vkDestroyDescriptorPool(device, descriptorPool, nullptr);
// 销毁缓冲区和内存
vkDestroyBuffer(device, vertices.buffer, nullptr);
vkFreeMemory(device, vertices.memory, nullptr);
// 销毁同步对象
for (auto& semaphore : presentCompleteSemaphores) {
vkDestroySemaphore(device, semaphore, nullptr);
}
// ... 以及所有创建的 Vulkan 对象
}
}
7、结论
SaschaWillems/Vulkan 仓库中的基础渲染示例为 Vulkan 图形编程提供了全面的介绍。从简单的三角形示例开始,逐步学习纹理和多重采样等更复杂的示例,开发者可以逐渐建立对 Vulkan 概念和最佳实践的理解。
虽然 Vulkan 比旧的图形 API 具有更陡峭的学习曲线,但这些示例表明,通过对基本概念的正确结构和理解,它变得易于管理,并提供了显著的性能优势。对内存管理、同步和管线状态的显式控制使开发者能够创建高度优化的图形应用程序。
当你学习这些示例时,请记住 Vulkan 的复杂性源于其设计理念,即通过要求应用程序更明确地表达其意图来使 GPU 驱动程序的工作更轻松。这种显式性使 Vulkan 相比旧 API 具有卓越的性能和可预测性。
学习和提升音视频开发技术,推荐你加入我们的知识星球:【关键帧的音视频开发圈】

版权声明:本文内容转自互联网,本文观点仅代表作者本人。本站仅提供信息存储空间服务,所有权归原作者所有。如发现本站有涉嫌抄袭侵权/违法违规的内容, 请发送邮件至1393616908@qq.com 举报,一经查实,本站将立刻删除。