
一、什么是离屏渲染在 Vulkan 中最常见的渲染流程是从 Swapchain 获取一张交换链图像把场景直接渲染到这张图像上提交给 Present Queue最终显示到屏幕上。这种方式可以称为屏幕渲染也可以称为直接渲染到交换链。而离屏渲染英文通常叫Offscreen Rendering指的是不把渲染结果直接写入 Swapchain Image而是写入我们自己创建的一张VkImage中。也就是说渲染目标从Swapchain Image变成User-created VkImage这张VkImage不直接显示在屏幕上而是可以继续作为纹理、后处理输入、阴影贴图、G-Buffer、反射贴图等资源使用。离屏渲染的本质可以概括为把渲染结果写入一张普通 Vulkan 图像资源而不是直接写入交换链图像。二、为什么需要离屏渲染离屏渲染是现代实时渲染系统中的基础能力。只要渲染流程稍微复杂一点基本都会涉及离屏渲染。常见用途包括后处理效果HDR 渲染Bloom 泛光阴影贴图延迟渲染 G-Buffer屏幕空间反射 SSR环境贴图反射与折射摄像机画面渲染小地图截图导出GPU 计算输入。三、后处理中的离屏渲染后处理是离屏渲染最典型的应用之一。例如Tone MappingBloomFXAATAASSAOSSR景深运动模糊LUT 调色。典型流程如下场景渲染 ↓ 离屏颜色图像 ↓ 后处理 Shader ↓ Swapchain Image ↓ Present在这个流程中场景不会直接渲染到屏幕而是先渲染到一张中间颜色纹理中。例如 HDR 渲染时常用格式是VK_FORMAT_R16G16B16A16_SFLOAT这样可以保存超过[0, 1]范围的高动态范围颜色。之后再通过 Tone Mapping 把 HDR 图像映射到普通显示器可以显示的 LDR 范围。四、阴影贴图中的离屏渲染Shadow Mapping 也是离屏渲染的典型应用。它的基本流程是从光源视角渲染场景深度 ↓ 得到一张 Depth Texture ↓ 主渲染阶段采样这张 Depth Texture ↓ 判断当前片元是否处于阴影中阴影贴图阶段只需要深度不需要颜色。因此可以创建一张深度图像VK_FORMAT_D32_SFLOAT并设置用途VK_IMAGE_USAGE_DEPTH_STENCIL_ATTACHMENT_BIT | VK_IMAGE_USAGE_SAMPLED_BIT其中VK_IMAGE_USAGE_DEPTH_STENCIL_ATTACHMENT_BIT表示这张图像可以作为深度附件VK_IMAGE_USAGE_SAMPLED_BIT表示这张图像后续可以被 Shader 采样。这就是阴影贴图的核心先把场景深度渲染到离屏图像再在主渲染阶段读取这张图像。五、延迟渲染中的 G-Buffer延迟渲染 Deferred Rendering 通常会先把几何信息写入多张离屏纹理中这些纹理合起来称为G-Buffer。常见 G-Buffer 内容包括Position Normal Albedo Metallic Roughness Depth渲染流程如下Geometry Pass ↓ 写入 G-Buffer ↓ Lighting Pass ↓ 读取 G-Buffer 并计算光照 ↓ 输出最终画面第一阶段负责收集几何信息第二阶段负责统一计算光照。这种渲染方式的前提就是能够把多个渲染结果写入不同的离屏图像中因此离屏渲染是延迟渲染的基础。六、离屏渲染和普通渲染的区别普通屏幕渲染通常写入的是Swapchain Image它的特点是由 Vulkan Swapchain 创建最终用于 Present格式通常由 Surface 和 Swapchain 决定数量由 Swapchain 决定生命周期依赖窗口大小和 Swapchain。离屏渲染写入的是User-created VkImage它的特点是由应用程序自己创建可以自由选择格式可以自由设置尺寸可以被 Shader 采样可以作为多个 Pass 之间的中间结果可以被复制到 CPU可以用于后处理、阴影、G-Buffer、反射等高级效果。对比如下对比项屏幕渲染离屏渲染渲染目标Swapchain Image自己创建的 VkImage是否直接显示是否是否可采样一般不直接采样通常用于采样常见用途最终画面输出后处理、阴影、G-Buffer、反射生命周期依赖 Swapchain由应用程序控制格式选择受 Surface 限制更自由是否适合多 Pass不适合非常适合七、离屏渲染需要哪些 Vulkan 对象一个完整的离屏渲染目标通常包含以下 Vulkan 对象VkImage VkDeviceMemory VkImageView VkSampler VkRenderPass VkFramebuffer它们的作用如下对象作用VkImage真正的图像资源VkDeviceMemory图像占用的 GPU 显存VkImageView图像访问视图VkSamplerShader 采样图像时使用VkRenderPass描述渲染附件和子通道VkFramebuffer绑定具体的 ImageView 到 RenderPass如果使用 Vulkan 1.3 的Dynamic Rendering则可以不显式创建VkRenderPass和VkFramebuffer。传统 RenderPass 路线VkImage VkDeviceMemory VkImageView VkSampler VkRenderPass VkFramebufferDynamic Rendering 路线VkImage VkDeviceMemory VkImageView VkSampler vkCmdBeginRendering vkCmdEndRendering八、离屏渲染的核心流程一个完整的离屏渲染流程通常如下1. 创建离屏 VkImage 2. 为 VkImage 分配显存 3. 创建 VkImageView 4. 创建 VkSampler 5. 创建 VkRenderPass 6. 创建 VkFramebuffer 7. 在 Command Buffer 中开始离屏渲染 8. 渲染场景到离屏图像 9. 转换图像布局 10. 在后续 Pass 中作为纹理采样 11. 最终输出到 Swapchain整体数据流可以表示为Scene Geometry ↓ Offscreen Color Image ↓ Post Process Fragment Shader ↓ Swapchain Image ↓ Present九、创建离屏颜色图像离屏渲染最核心的资源是VkImage。如果我们要创建一张用于颜色渲染并且后续可以被 Shader 采样的离屏图像需要设置如下 UsageVK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT | VK_IMAGE_USAGE_SAMPLED_BIT如果后续还想把它拷贝到 CPU例如截图则还可以加上VK_IMAGE_USAGE_TRANSFER_SRC_BIT可以先定义一个简单的离屏图像结构体struct OffscreenImage { VkImage image VK_NULL_HANDLE; VkDeviceMemory memory VK_NULL_HANDLE; VkImageView view VK_NULL_HANDLE; VkSampler sampler VK_NULL_HANDLE; VkFormat format VK_FORMAT_R8G8B8A8_UNORM; uint32_t width 0; uint32_t height 0; };创建图像VkImageCreateInfo imageInfo{}; imageInfo.sType VK_STRUCTURE_TYPE_IMAGE_CREATE_INFO; imageInfo.imageType VK_IMAGE_TYPE_2D; imageInfo.extent.width width; imageInfo.extent.height height; imageInfo.extent.depth 1; imageInfo.mipLevels 1; imageInfo.arrayLayers 1; imageInfo.format VK_FORMAT_R8G8B8A8_UNORM; imageInfo.tiling VK_IMAGE_TILING_OPTIMAL; imageInfo.initialLayout VK_IMAGE_LAYOUT_UNDEFINED; imageInfo.usage VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT | VK_IMAGE_USAGE_SAMPLED_BIT | VK_IMAGE_USAGE_TRANSFER_SRC_BIT; imageInfo.samples VK_SAMPLE_COUNT_1_BIT; imageInfo.sharingMode VK_SHARING_MODE_EXCLUSIVE; vkCreateImage(device, imageInfo, nullptr, offscreen.image);查询显存需求VkMemoryRequirements memRequirements; vkGetImageMemoryRequirements(device, offscreen.image, memRequirements);分配显存VkMemoryAllocateInfo allocInfo{}; allocInfo.sType VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO; allocInfo.allocationSize memRequirements.size; allocInfo.memoryTypeIndex findMemoryType( memRequirements.memoryTypeBits, VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT ); vkAllocateMemory(device, allocInfo, nullptr, offscreen.memory);绑定图像和显存vkBindImageMemory(device, offscreen.image, offscreen.memory, 0);这里使用VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT因为离屏图像主要由 GPU 写入和读取一般不需要 CPU 直接访问。十、创建 Image ViewVkImage是底层图像资源而 Shader、Framebuffer、RenderPass 实际绑定时通常使用的是VkImageView。创建颜色图像的 Image ViewVkImageViewCreateInfo viewInfo{}; viewInfo.sType VK_STRUCTURE_TYPE_IMAGE_VIEW_CREATE_INFO; viewInfo.image offscreen.image; viewInfo.viewType VK_IMAGE_VIEW_TYPE_2D; viewInfo.format offscreen.format; viewInfo.subresourceRange.aspectMask VK_IMAGE_ASPECT_COLOR_BIT; viewInfo.subresourceRange.baseMipLevel 0; viewInfo.subresourceRange.levelCount 1; viewInfo.subresourceRange.baseArrayLayer 0; viewInfo.subresourceRange.layerCount 1; vkCreateImageView(device, viewInfo, nullptr, offscreen.view);需要注意颜色图像使用 VK_IMAGE_ASPECT_COLOR_BIT 深度图像使用 VK_IMAGE_ASPECT_DEPTH_BIT 模板图像使用 VK_IMAGE_ASPECT_STENCIL_BIT十一、创建 Sampler如果离屏渲染结果后续要在 Shader 中作为纹理读取就需要创建VkSampler。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_CLAMP_TO_EDGE; samplerInfo.addressModeV VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE; samplerInfo.addressModeW VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE; samplerInfo.anisotropyEnable VK_FALSE; samplerInfo.maxAnisotropy 1.0f; samplerInfo.borderColor VK_BORDER_COLOR_INT_OPAQUE_BLACK; samplerInfo.unnormalizedCoordinates VK_FALSE; samplerInfo.compareEnable VK_FALSE; samplerInfo.compareOp VK_COMPARE_OP_ALWAYS; samplerInfo.mipmapMode VK_SAMPLER_MIPMAP_MODE_LINEAR; samplerInfo.minLod 0.0f; samplerInfo.maxLod 1.0f; samplerInfo.mipLodBias 0.0f; vkCreateSampler(device, samplerInfo, nullptr, offscreen.sampler);对于屏幕空间后处理纹理通常使用VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE而不是VK_SAMPLER_ADDRESS_MODE_REPEAT原因是后处理纹理一般不希望边缘发生重复采样否则可能出现边缘污染或者画面接缝。十二、创建离屏 RenderPass传统 Vulkan 渲染流程需要VkRenderPass描述附件的格式、加载方式、存储方式和布局转换。一个简单的离屏 Color RenderPass 可以这样写VkAttachmentDescription colorAttachment{}; colorAttachment.format offscreenFormat; colorAttachment.samples VK_SAMPLE_COUNT_1_BIT; colorAttachment.loadOp VK_ATTACHMENT_LOAD_OP_CLEAR; colorAttachment.storeOp VK_ATTACHMENT_STORE_OP_STORE; colorAttachment.stencilLoadOp VK_ATTACHMENT_LOAD_OP_DONT_CARE; colorAttachment.stencilStoreOp VK_ATTACHMENT_STORE_OP_DONT_CARE; colorAttachment.initialLayout VK_IMAGE_LAYOUT_UNDEFINED; colorAttachment.finalLayout VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL;这里有几个关键点。1. loadOpVK_ATTACHMENT_LOAD_OP_CLEAR表示每次开始离屏渲染时先清空颜色图像。如果希望保留上一帧内容可以使用VK_ATTACHMENT_LOAD_OP_LOAD但这要求图像原来的内容和布局都是有效的。2. storeOpVK_ATTACHMENT_STORE_OP_STORE表示渲染结束后保留结果。离屏渲染通常必须使用STORE因为后续还要采样这张图像。如果设置成VK_ATTACHMENT_STORE_OP_DONT_CARE那么渲染结果可能会被丢弃后续采样内容不可靠。3. finalLayoutVK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL表示 RenderPass 结束后图像进入 Shader 只读布局方便后续作为纹理采样。如果离屏图像后面还要继续作为 Color Attachment 写入则可以使用VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL但在真正采样之前仍然要转换到VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL创建 Color Attachment ReferenceVkAttachmentReference colorAttachmentRef{}; colorAttachmentRef.attachment 0; colorAttachmentRef.layout VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL;创建 SubpassVkSubpassDescription subpass{}; subpass.pipelineBindPoint VK_PIPELINE_BIND_POINT_GRAPHICS; subpass.colorAttachmentCount 1; subpass.pColorAttachments colorAttachmentRef;创建 RenderPassVkRenderPassCreateInfo renderPassInfo{}; renderPassInfo.sType VK_STRUCTURE_TYPE_RENDER_PASS_CREATE_INFO; renderPassInfo.attachmentCount 1; renderPassInfo.pAttachments colorAttachment; renderPassInfo.subpassCount 1; renderPassInfo.pSubpasses subpass; vkCreateRenderPass(device, renderPassInfo, nullptr, offscreenRenderPass);十三、创建 FramebufferVkFramebuffer的作用是把具体的VkImageView绑定到VkRenderPass中的附件描述上。VkImageView attachments[] { offscreen.view }; VkFramebufferCreateInfo framebufferInfo{}; framebufferInfo.sType VK_STRUCTURE_TYPE_FRAMEBUFFER_CREATE_INFO; framebufferInfo.renderPass offscreenRenderPass; framebufferInfo.attachmentCount 1; framebufferInfo.pAttachments attachments; framebufferInfo.width width; framebufferInfo.height height; framebufferInfo.layers 1; vkCreateFramebuffer(device, framebufferInfo, nullptr, offscreenFramebuffer);可以这样理解它们之间的关系VkRenderPass 描述“渲染需要什么类型的附件” VkFramebuffer 绑定“具体是哪几张图像” VkImageView 表示“图像的访问视图” VkImage 表示“真正的图像资源”十四、录制离屏渲染命令离屏渲染的命令和普通渲染非常相似只是绑定的 RenderPass 和 Framebuffer 不一样。VkClearValue clearColor{}; clearColor.color {{0.0f, 0.0f, 0.0f, 1.0f}}; VkRenderPassBeginInfo renderPassInfo{}; renderPassInfo.sType VK_STRUCTURE_TYPE_RENDER_PASS_BEGIN_INFO; renderPassInfo.renderPass offscreenRenderPass; renderPassInfo.framebuffer offscreenFramebuffer; renderPassInfo.renderArea.offset {0, 0}; renderPassInfo.renderArea.extent {width, height}; renderPassInfo.clearValueCount 1; renderPassInfo.pClearValues clearColor; vkCmdBeginRenderPass( commandBuffer, renderPassInfo, VK_SUBPASS_CONTENTS_INLINE ); vkCmdBindPipeline( commandBuffer, VK_PIPELINE_BIND_POINT_GRAPHICS, scenePipeline ); vkCmdBindDescriptorSets( commandBuffer, VK_PIPELINE_BIND_POINT_GRAPHICS, scenePipelineLayout, 0, 1, sceneDescriptorSet, 0, nullptr ); vkCmdBindVertexBuffers(commandBuffer, 0, 1, vertexBuffers, offsets); vkCmdBindIndexBuffer(commandBuffer, indexBuffer, 0, VK_INDEX_TYPE_UINT32); vkCmdDrawIndexed(commandBuffer, indexCount, 1, 0, 0, 0); vkCmdEndRenderPass(commandBuffer);这一步结束后场景已经被渲染到了offscreen.image中。十五、把离屏图像作为纹理采样离屏渲染完成后下一步通常是在另一个 Pass 中采样它。首先准备VkDescriptorImageInfoVkDescriptorImageInfo imageInfo{}; imageInfo.imageLayout VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL; imageInfo.imageView offscreen.view; imageInfo.sampler offscreen.sampler;然后写入 Descriptor SetVkWriteDescriptorSet descriptorWrite{}; descriptorWrite.sType VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET; descriptorWrite.dstSet postProcessDescriptorSet; descriptorWrite.dstBinding 0; descriptorWrite.dstArrayElement 0; descriptorWrite.descriptorType VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER; descriptorWrite.descriptorCount 1; descriptorWrite.pImageInfo imageInfo; vkUpdateDescriptorSets(device, 1, descriptorWrite, 0, nullptr);Fragment Shader 中可以这样采样layout(set 0, binding 0) uniform sampler2D offscreenTexture; layout(location 0) in vec2 inUV; layout(location 0) out vec4 outColor; void main() { vec3 color texture(offscreenTexture, inUV).rgb; // 简单后处理反色 color vec3(1.0) - color; outColor vec4(color, 1.0); }后处理 Pass 通常会绘制一个全屏三角形或全屏四边形。推荐使用全屏三角形vkCmdDraw(commandBuffer, 3, 1, 0, 0);全屏三角形可以减少顶点数量也可以避免全屏四边形中间对角线带来的插值问题。十六、图像布局转换是离屏渲染的核心难点Vulkan 不会自动帮开发者管理图像状态。每张VkImage在不同阶段需要处于合适的VkImageLayout。常见布局包括VK_IMAGE_LAYOUT_UNDEFINED VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL VK_IMAGE_LAYOUT_DEPTH_STENCIL_ATTACHMENT_OPTIMAL VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL VK_IMAGE_LAYOUT_PRESENT_SRC_KHR离屏颜色图像的典型状态流转是UNDEFINED ↓ COLOR_ATTACHMENT_OPTIMAL ↓ SHADER_READ_ONLY_OPTIMAL如果要截图则可能是SHADER_READ_ONLY_OPTIMAL ↓ TRANSFER_SRC_OPTIMAL传统 RenderPass 可以通过initialLayout和finalLayout处理部分布局转换但跨 Pass 的同步仍然需要开发者明确控制。手动 Barrier 示例VkImageMemoryBarrier barrier{}; barrier.sType VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER; barrier.oldLayout VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL; barrier.newLayout VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL; barrier.srcQueueFamilyIndex VK_QUEUE_FAMILY_IGNORED; barrier.dstQueueFamilyIndex VK_QUEUE_FAMILY_IGNORED; barrier.image offscreen.image; barrier.subresourceRange.aspectMask VK_IMAGE_ASPECT_COLOR_BIT; barrier.subresourceRange.baseMipLevel 0; barrier.subresourceRange.levelCount 1; barrier.subresourceRange.baseArrayLayer 0; barrier.subresourceRange.layerCount 1; barrier.srcAccessMask VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT; barrier.dstAccessMask VK_ACCESS_SHADER_READ_BIT; vkCmdPipelineBarrier( commandBuffer, VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT, VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT, 0, 0, nullptr, 0, nullptr, 1, barrier );这段 Barrier 的含义是等待颜色附件写入完成 ↓ 把图像布局切换为 Shader Read Only ↓ 允许 Fragment Shader 读取这张图像如果忘记同步可能出现以下问题采样结果是黑色采样结果是上一帧画面闪烁Validation Layer 报错不同显卡表现不一致。十七、带深度附件的离屏渲染真实场景渲染通常不只需要颜色图像还需要深度图像。一个常见离屏渲染目标包含Color Attachment Depth Attachment颜色图像可以这样设置VK_FORMAT_R8G8B8A8_UNORM VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT | VK_IMAGE_USAGE_SAMPLED_BIT深度图像可以这样设置VK_FORMAT_D32_SFLOAT VK_IMAGE_USAGE_DEPTH_STENCIL_ATTACHMENT_BIT如果深度图像后续也要采样例如 SSAO 或 Shadow Map则需要VK_IMAGE_USAGE_DEPTH_STENCIL_ATTACHMENT_BIT | VK_IMAGE_USAGE_SAMPLED_BITRenderPass 中添加深度附件VkAttachmentDescription depthAttachment{}; depthAttachment.format depthFormat; depthAttachment.samples VK_SAMPLE_COUNT_1_BIT; depthAttachment.loadOp VK_ATTACHMENT_LOAD_OP_CLEAR; depthAttachment.storeOp VK_ATTACHMENT_STORE_OP_DONT_CARE; depthAttachment.stencilLoadOp VK_ATTACHMENT_LOAD_OP_DONT_CARE; depthAttachment.stencilStoreOp VK_ATTACHMENT_STORE_OP_DONT_CARE; depthAttachment.initialLayout VK_IMAGE_LAYOUT_UNDEFINED; depthAttachment.finalLayout VK_IMAGE_LAYOUT_DEPTH_STENCIL_ATTACHMENT_OPTIMAL;如果深度图像后续要被采样storeOp应该使用VK_ATTACHMENT_STORE_OP_STORE并且渲染完成后需要转换到只读布局例如VK_IMAGE_LAYOUT_DEPTH_STENCIL_READ_ONLY_OPTIMAL或者在部分使用场景中转换到VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL具体选择取决于深度图像格式、Vulkan 版本以及后续访问方式。十八、多 Render TargetG-Buffer 离屏渲染如果要实现延迟渲染需要多个颜色附件。Fragment Shader 可能会输出多组数据layout(location 0) out vec4 outPosition; layout(location 1) out vec4 outNormal; layout(location 2) out vec4 outAlbedo; layout(location 3) out vec4 outMaterial; void main() { outPosition vec4(worldPosition, 1.0); outNormal vec4(normalize(worldNormal), 1.0); outAlbedo vec4(baseColor, 1.0); outMaterial vec4(metallic, roughness, ao, 1.0); }对应 Vulkan 中需要创建多张离屏图像Position Image Normal Image Albedo Image Material Image Depth ImageSubpass 中设置多个 Color Attachmentstd::arrayVkAttachmentReference, 4 colorRefs{}; colorRefs[0] {0, VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL}; colorRefs[1] {1, VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL}; colorRefs[2] {2, VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL}; colorRefs[3] {3, VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL}; VkSubpassDescription subpass{}; subpass.pipelineBindPoint VK_PIPELINE_BIND_POINT_GRAPHICS; subpass.colorAttachmentCount static_castuint32_t(colorRefs.size()); subpass.pColorAttachments colorRefs.data();Framebuffer 中也需要绑定多张 ImageViewstd::arrayVkImageView, 5 attachments { positionView, normalView, albedoView, materialView, depthView };这就是典型的 G-Buffer 结构。十九、Vulkan 1.3 Dynamic Rendering 下的离屏渲染传统 Vulkan 需要提前创建VkRenderPass VkFramebuffer这让离屏渲染的配置较为繁琐。Vulkan 1.3 引入了 Dynamic Rendering可以直接使用vkCmdBeginRendering vkCmdEndRendering不再强制创建 RenderPass 和 Framebuffer。颜色附件描述VkRenderingAttachmentInfo colorAttachment{}; colorAttachment.sType VK_STRUCTURE_TYPE_RENDERING_ATTACHMENT_INFO; colorAttachment.imageView offscreen.view; colorAttachment.imageLayout VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL; colorAttachment.loadOp VK_ATTACHMENT_LOAD_OP_CLEAR; colorAttachment.storeOp VK_ATTACHMENT_STORE_OP_STORE; VkClearValue clearValue{}; clearValue.color {{0.0f, 0.0f, 0.0f, 1.0f}}; colorAttachment.clearValue clearValue;Rendering InfoVkRenderingInfo renderingInfo{}; renderingInfo.sType VK_STRUCTURE_TYPE_RENDERING_INFO; renderingInfo.renderArea.offset {0, 0}; renderingInfo.renderArea.extent {width, height}; renderingInfo.layerCount 1; renderingInfo.colorAttachmentCount 1; renderingInfo.pColorAttachments colorAttachment;开始渲染vkCmdBeginRendering(commandBuffer, renderingInfo); vkCmdBindPipeline( commandBuffer, VK_PIPELINE_BIND_POINT_GRAPHICS, scenePipeline ); vkCmdDrawIndexed(commandBuffer, indexCount, 1, 0, 0, 0); vkCmdEndRendering(commandBuffer);Dynamic Rendering 的优势是减少 RenderPass / Framebuffer 对象管理更适合现代渲染器更适合多 Pass 和后处理更适合 Render Graph管线配置更灵活。但使用 Dynamic Rendering 时图像布局和同步更需要开发者自己明确处理。二十、离屏渲染中的同步问题离屏渲染经常涉及多个 Pass。例如Pass 1: 渲染场景到 offscreen image Pass 2: 采样 offscreen image 做后处理 Pass 3: 输出到 swapchainPass 1 写入图像Pass 2 读取图像这里存在典型的读写依赖Color Attachment Write ↓ Fragment Shader Read对应同步关系是srcStageMask VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT; srcAccessMask VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT; dstStageMask VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT; dstAccessMask VK_ACCESS_SHADER_READ_BIT;如果使用 Synchronization2可以写得更加明确VkImageMemoryBarrier2 barrier{}; barrier.sType VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER_2; barrier.srcStageMask VK_PIPELINE_STAGE_2_COLOR_ATTACHMENT_OUTPUT_BIT; barrier.srcAccessMask VK_ACCESS_2_COLOR_ATTACHMENT_WRITE_BIT; barrier.dstStageMask VK_PIPELINE_STAGE_2_FRAGMENT_SHADER_BIT; barrier.dstAccessMask VK_ACCESS_2_SHADER_SAMPLED_READ_BIT; barrier.oldLayout VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL; barrier.newLayout VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL; barrier.image offscreen.image; barrier.subresourceRange.aspectMask VK_IMAGE_ASPECT_COLOR_BIT; barrier.subresourceRange.baseMipLevel 0; barrier.subresourceRange.levelCount 1; barrier.subresourceRange.baseArrayLayer 0; barrier.subresourceRange.layerCount 1; VkDependencyInfo dependencyInfo{}; dependencyInfo.sType VK_STRUCTURE_TYPE_DEPENDENCY_INFO; dependencyInfo.imageMemoryBarrierCount 1; dependencyInfo.pImageMemoryBarriers barrier; vkCmdPipelineBarrier2(commandBuffer, dependencyInfo);在现代 Vulkan 工程中更推荐使用 Synchronization2因为它表达更清晰也更符合新版本 Vulkan 的设计方向。二十一、离屏渲染图像尺寸如何选择离屏图像不一定要和窗口大小一致。1. 与 Swapchain 尺寸一致适合主场景渲染、后处理输入offscreenWidth swapchainExtent.width; offscreenHeight swapchainExtent.height;优点是画质稳定采样简单。2. 半分辨率或四分之一分辨率适合 Bloom、SSAO、SSR、体积光等效果offscreenWidth swapchainExtent.width / 2; offscreenHeight swapchainExtent.height / 2;优点是性能更好。缺点是需要上采样可能出现模糊或边缘伪影。3. 固定尺寸适合阴影贴图shadowMapSize 2048;或者shadowMapSize 4096;阴影贴图尺寸越大阴影越清晰但显存占用和渲染成本也越高。二十二、离屏渲染常用格式选择不同用途应该选择不同格式。1. 普通颜色缓冲VK_FORMAT_R8G8B8A8_UNORM VK_FORMAT_B8G8R8A8_UNORM适合普通 LDR 渲染。2. HDR 场景颜色VK_FORMAT_R16G16B16A16_SFLOAT适合 HDR、Bloom、Tone Mapping。3. 法线缓冲VK_FORMAT_R16G16B16A16_SFLOAT VK_FORMAT_A2B10G10R10_UNORM_PACK32法线对精度比较敏感不建议随便使用低精度格式。4. 深度缓冲VK_FORMAT_D32_SFLOAT VK_FORMAT_D24_UNORM_S8_UINT VK_FORMAT_D32_SFLOAT_S8_UINT只需要深度时VK_FORMAT_D32_SFLOAT很常见。5. 位置缓冲VK_FORMAT_R16G16B16A16_SFLOAT VK_FORMAT_R32G32B32A32_SFLOAT位置缓冲精度要求高但显存消耗也大。实际工程中经常通过深度重建世界坐标而不是直接存储 Position。二十三、一个典型后处理离屏渲染管线完整后处理流程如下Frame Begin ↓ Acquire Swapchain Image ↓ Pass 1: Scene Pass - Render scene to HDR offscreen color image - Render depth to offscreen depth image ↓ Barrier - HDR image: COLOR_ATTACHMENT_OPTIMAL - SHADER_READ_ONLY_OPTIMAL ↓ Pass 2: Post Process Pass - Sample HDR image - Tone mapping - Gamma correction - Output to Swapchain Image ↓ Present后处理 Fragment Shader 示例layout(set 0, binding 0) uniform sampler2D hdrTexture; layout(location 0) in vec2 inUV; layout(location 0) out vec4 outColor; void main() { vec3 hdrColor texture(hdrTexture, inUV).rgb; // Reinhard tone mapping vec3 mapped hdrColor / (hdrColor vec3(1.0)); // Gamma correction mapped pow(mapped, vec3(1.0 / 2.2)); outColor vec4(mapped, 1.0); }这个流程是现代渲染器的基础框架。后续加入 Bloom、FXAA、TAA、SSAO 等效果都可以围绕它继续扩展。二十四、离屏渲染与 Render Graph当渲染 Pass 越来越多时手动管理图像布局和同步会变得困难。一个复杂渲染器可能包含Shadow Pass Depth Prepass GBuffer Pass SSAO Pass Lighting Pass Bloom Downsample Pass Bloom Upsample Pass Tone Mapping Pass UI Pass Present Pass每个 Pass 都可能读写不同图像。这时可以引入 Render Graph。Render Graph 的核心思想是声明每个 Pass 读什么资源、写什么资源 由系统自动推导资源生命周期 由系统自动插入 Barrier 由系统自动复用临时图像离屏渲染是 Render Graph 的基础因为 Render Graph 管理的核心资源通常就是各种离屏纹理。二十五、常见错误与排查方法1. 离屏图像采样出来是黑色常见原因没有设置VK_IMAGE_USAGE_SAMPLED_BIT没有创建 SamplerDescriptor Set 没有正确更新Image Layout 不是VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMALRenderPass 的storeOp设置成了DONT_CARE没有正确同步Shader binding 写错。重点检查VkDescriptorImageInfo::imageLayout VkAttachmentDescription::storeOp VkImageCreateInfo::usage Pipeline Barrier2. Validation Layer 报 layout mismatch说明图像当前布局和使用时声明的布局不一致。例如 Descriptor 中写的是VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL但图像实际还处于VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL这时需要在采样前插入 Barrier。3. 图像大小变化后崩溃窗口 resize 后Swapchain 会重建。如果离屏图像尺寸依赖 Swapchain也必须一起重建destroyOffscreenResources(); createOffscreenResources(newWidth, newHeight); updateDescriptorSets();否则可能出现Framebuffer 尺寸不匹配ImageView 已销毁但 Descriptor 仍引用旧资源采样旧图像GPU 崩溃。4. 深度图无法采样常见原因深度图没有VK_IMAGE_USAGE_SAMPLED_BITImageView 的aspectMask没有设置VK_IMAGE_ASPECT_DEPTH_BITSampler 的compareEnable配置错误布局没有转为只读格式不支持采样。可以通过以下函数检查格式特性vkGetPhysicalDeviceFormatProperties(...)确认该格式是否支持VK_FORMAT_FEATURE_SAMPLED_IMAGE_BIT5. 后处理画面上下颠倒可能原因纹理坐标系差异投影矩阵 Y 翻转全屏三角形 UV 生成错误从 OpenGL 迁移到 Vulkan 时没有处理坐标差异。Vulkan 的 NDC 和 OpenGL 不完全相同迁移代码时尤其容易出现这个问题。二十六、工程封装建议实际工程中可以封装一个OffscreenRenderTarget类class OffscreenRenderTarget { public: void create( VkDevice device, VkPhysicalDevice physicalDevice, uint32_t width, uint32_t height, VkFormat colorFormat, VkFormat depthFormat ); void destroy(VkDevice device); VkImageView getColorView() const; VkSampler getSampler() const; VkFramebuffer getFramebuffer() const; VkRenderPass getRenderPass() const; private: OffscreenImage color; OffscreenImage depth; VkRenderPass renderPass VK_NULL_HANDLE; VkFramebuffer framebuffer VK_NULL_HANDLE; uint32_t width 0; uint32_t height 0; };对于现代渲染器更推荐拆成多个职责清晰的模块Texture2D 管理 VkImage / VkImageView / VkSampler RenderTarget 管理多个附件 RenderPass 管理一个渲染阶段 RenderGraph 管理 Pass 之间的依赖不要把所有离屏渲染逻辑全部写死在一个类里。随着后处理、阴影、延迟渲染、体积光、SSR 等效果不断增加资源管理会迅速复杂化。二十七、离屏渲染的本质总结Vulkan 离屏渲染并不是一个独立 API而是一种资源使用方式。它的核心是创建自己的 VkImage 让它具备 Color / Depth Attachment 用途 把它绑定到 Framebuffer 或 Dynamic Rendering 渲染完成后转换布局 再作为纹理、输入附件或拷贝源使用最关键的三个点是1. VkImage usage 必须正确 2. Image Layout 必须正确 3. Pass 之间同步必须正确只要理解这三点离屏渲染就不再神秘。从渲染系统角度看离屏渲染是现代图形管线的分水岭。直接渲染到 Swapchain 只能完成最基础的画面输出而离屏渲染让我们可以把渲染拆成多个阶段从而实现后处理、阴影、延迟渲染、反射、环境贴图、HDR、Bloom、SSAO 等高级效果。可以说掌握离屏渲染才真正开始进入 Vulkan 实时渲染工程的核心区域。