本章介绍 Vulkan 中的存储图像和纹理元素缓冲区,阐释其用途、使用方法及最佳实践。
存储图像
存储图像是一种描述符类型(VK_DESCRIPTOR_TYPE_STORAGE_IMAGE),允许着色器在不使用固定功能图形管线的情况下对图像进行读写操作,该特性在计算着色器和高级渲染技术中尤为实用。
创建存储图像
创建存储图像需执行以下步骤:
- 创建带有
VK_IMAGE_USAGE_STORAGE_BIT标识的VkImage - 为该图像创建
VkImageView - 创建包含
VK_DESCRIPTOR_TYPE_STORAGE_IMAGE类型绑定的描述符集布局 - 使用图像视图更新描述符集
cpp
// 创建带有存储使用标识的图像
VkImageCreateInfo imageInfo = {};
imageInfo.sType = VK_STRUCTURE_TYPE_IMAGE_CREATE_INFO;
imageInfo.imageType = VK_IMAGE_TYPE_2D;
imageInfo.format = VK_FORMAT_R32G32B32A32_SFLOAT; // 选择支持存储操作的格式
imageInfo.extent = {width, height, 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_STORAGE_BIT | VK_IMAGE_USAGE_TRANSFER_SRC_BIT;
imageInfo.sharingMode = VK_SHARING_MODE_EXCLUSIVE;
imageInfo.initialLayout = VK_IMAGE_LAYOUT_UNDEFINED;
// 创建图像视图
VkImageViewCreateInfo viewInfo = {};
viewInfo.sType = VK_STRUCTURE_TYPE_IMAGE_VIEW_CREATE_INFO;
viewInfo.image = storageImage;
viewInfo.viewType = VK_IMAGE_VIEW_TYPE_2D;
viewInfo.format = VK_FORMAT_R32G32B32A32_SFLOAT;
viewInfo.subresourceRange.aspectMask = VK_IMAGE_ASPECT_COLOR_BIT;
viewInfo.subresourceRange.baseMipLevel = 0;
viewInfo.subresourceRange.levelCount = 1;
viewInfo.subresourceRange.baseArrayLayer = 0;
viewInfo.subresourceRange.layerCount = 1;
在着色器中使用存储图像
在 GLSL 中,存储图像通过带格式限定符的image类型声明,使用imageLoad和imageStore函数对图像进行读写。
// 对应VK_FORMAT_R32G32B32A32_SFLOAT格式
layout(set = 0, binding = 0, rgba32f) uniform image2D storageImage;
void main() {
ivec2 texelCoord = ivec2(gl_GlobalInvocationID.xy);
// 从图像中读取数据
vec4 value = imageLoad(storageImage, texelCoord);
// 修改数据
value = value * 2.0;
// 将数据写回图像
imageStore(storageImage, texelCoord, value);
}
在 Slang 中,存储图像的声明方式与 HLSL 类似,使用RWTexture2D类型,通过Load和Store方法对图像进行读写。
// 对应VK_FORMAT_R32G32B32A32_SFLOAT格式
[[vk::binding(0, 0)]]
[[vk::image_format("rgba32f")]]
RWTexture2D<float4> storageImage;
[numthreads(8, 8, 1)]
void main(uint3 dispatchThreadID : SV_DispatchThreadID)
{
int2 texelCoord = int2(dispatchThreadID.xy);
// 从图像中读取数据
float4 value = storageImage.Load(texelCoord);
// 修改数据
value = value * 2.0;
// 将数据写回图像
storageImage[texelCoord] = value;
}
对应的 SPIR-V 汇编代码:
Swift
OpDecorate %storageImage DescriptorSet 0
OpDecorate %storageImage Binding 0
%rgba32f = OpTypeImage %float 2D 0 0 0 2 Rgba32f
%ptr = OpTypePointer UniformConstant %rgba32f
%storageImage = OpVariable %ptr UniformConstant
存储图像的图像格式
并非所有图像格式都支持存储操作,VkFormatProperties中的VK_FORMAT_FEATURE_STORAGE_IMAGE_BIT标识用于指示某一格式是否可用于存储图像。
cpp
VkFormatProperties formatProperties;
vkGetPhysicalDeviceFormatProperties(physicalDevice, format, &formatProperties);
if (!(formatProperties.optimalTilingFeatures & VK_FORMAT_FEATURE_STORAGE_IMAGE_BIT)) {
// 该格式不支持存储图像操作
}
通常支持存储操作的常见格式包括:
VK_FORMAT_R32G32B32A32_SFLOATVK_FORMAT_R32G32B32A32_UINTVK_FORMAT_R32G32B32A32_SINTVK_FORMAT_R8G8B8A8_UNORMVK_FORMAT_R8G8B8A8_UINT
存储图像的同步操作
使用存储图像时,必须执行正确的同步操作以避免竞争条件,存储图像的读写操作通常使用VK_IMAGE_LAYOUT_GENERAL布局。
cpp
VkImageMemoryBarrier barrier = {};
barrier.sType = VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER;
barrier.oldLayout = VK_IMAGE_LAYOUT_UNDEFINED;
barrier.newLayout = VK_IMAGE_LAYOUT_GENERAL;
barrier.srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED;
barrier.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED;
barrier.image = storageImage;
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 = 0;
barrier.dstAccessMask = VK_ACCESS_SHADER_WRITE_BIT;
vkCmdPipelineBarrier(
commandBuffer,
VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT,
VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT,
0,
0, nullptr,
0, nullptr,
1, &barrier
);
在计算着色器的写入操作和读取操作之间进行布局转换的代码:
cpp
barrier.srcAccessMask = VK_ACCESS_SHADER_WRITE_BIT;
barrier.dstAccessMask = VK_ACCESS_SHADER_READ_BIT;
vkCmdPipelineBarrier(
commandBuffer,
VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT,
VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT,
0,
0, nullptr,
0, nullptr,
1, &barrier
);
纹理元素缓冲区
纹理元素缓冲区允许着色器通过类纹理操作访问缓冲区数据,主要分为两种类型:
- 统一纹理元素缓冲区(
VK_DESCRIPTOR_TYPE_UNIFORM_TEXEL_BUFFER):仅支持只读访问 - 存储纹理元素缓冲区(
VK_DESCRIPTOR_TYPE_STORAGE_TEXEL_BUFFER):支持读写访问
创建纹理元素缓冲区
创建纹理元素缓冲区需执行以下步骤:
- 创建带有相应使用标识的
VkBuffer - 为该缓冲区创建
VkBufferView - 创建包含相应纹理元素缓冲区类型绑定的描述符集布局
- 使用缓冲区视图更新描述符集
cpp
// 创建缓冲区
VkBufferCreateInfo bufferInfo = {};
bufferInfo.sType = VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO;
bufferInfo.size = size;
bufferInfo.usage = VK_BUFFER_USAGE_UNIFORM_TEXEL_BUFFER_BIT; // 或VK_BUFFER_USAGE_STORAGE_TEXEL_BUFFER_BIT
bufferInfo.sharingMode = VK_SHARING_MODE_EXCLUSIVE;
// 创建缓冲区视图
VkBufferViewCreateInfo viewInfo = {};
viewInfo.sType = VK_STRUCTURE_TYPE_BUFFER_VIEW_CREATE_INFO;
viewInfo.buffer = buffer;
viewInfo.format = VK_FORMAT_R32G32B32A32_SFLOAT; // 选择支持纹理元素缓冲区操作的格式
viewInfo.offset = 0;
viewInfo.range = size;
VkBufferView bufferView;
vkCreateBufferView(device, &viewInfo, nullptr, &bufferView);
在着色器中使用统一纹理元素缓冲区
在 GLSL 中,统一纹理元素缓冲区通过textureBuffer类型声明,使用texelFetch函数从缓冲区中读取数据。
layout(set = 0, binding = 0) uniform textureBuffer uniformTexelBuffer;
void main() {
// 从纹理元素缓冲区中读取数据
vec4 value = texelFetch(uniformTexelBuffer, int(gl_GlobalInvocationID.x));
// 使用读取到的数据
// ...
}
在 Slang 中,统一纹理元素缓冲区通过Buffer类型声明,使用Load方法从缓冲区中读取数据。
[[vk::binding(0, 0)]]
Buffer<float4> uniformTexelBuffer;
[numthreads(64, 1, 1)]
void main(uint3 dispatchThreadID : SV_DispatchThreadID)
{
// 从纹理元素缓冲区中读取数据
float4 value = uniformTexelBuffer.Load(dispatchThreadID.x);
// 使用读取到的数据
// ...
}
对应的 SPIR-V 汇编代码:
Swift
OpDecorate %uniformTexelBuffer DescriptorSet 0
OpDecorate %uniformTexelBuffer Binding 0
%texelBuffer = OpTypeImage %float Buffer 0 0 0 1 Unknown
%ptr = OpTypePointer UniformConstant %texelBuffer
%uniformTexelBuffer = OpVariable %ptr UniformConstant
在着色器中使用存储纹理元素缓冲区
在 GLSL 中,存储纹理元素缓冲区通过带格式限定符的imageBuffer类型声明,使用imageLoad和imageStore函数对缓冲区进行读写。
// 对应VK_FORMAT_R32G32B32A32_SFLOAT格式
layout(set = 0, binding = 0, rgba32f) uniform imageBuffer storageTexelBuffer;
void main() {
int index = int(gl_GlobalInvocationID.x);
// 从纹理元素缓冲区中读取数据
vec4 value = imageLoad(storageTexelBuffer, index);
// 修改数据
value = value * 2.0;
// 将数据写回纹理元素缓冲区
imageStore(storageTexelBuffer, index, value);
}
在 Slang 中,存储纹理元素缓冲区通过RWBuffer类型声明,使用Load方法和数组索引对缓冲区进行读写。
// 对应VK_FORMAT_R32G32B32A32_SFLOAT格式
[[vk::binding(0, 0)]]
[[vk::image_format("rgba32f")]]
RWBuffer<float4> storageTexelBuffer;
[numthreads(64, 1, 1)]
void main(uint3 dispatchThreadID : SV_DispatchThreadID)
{
int index = int(dispatchThreadID.x);
// 从纹理元素缓冲区中读取数据
float4 value = storageTexelBuffer.Load(index);
// 修改数据
value = value * 2.0;
// 将数据写回纹理元素缓冲区
storageTexelBuffer[index] = value;
}
对应的 SPIR-V 汇编代码:
Swift
OpDecorate %storageTexelBuffer DescriptorSet 0
OpDecorate %storageTexelBuffer Binding 0
%rgba32f = OpTypeImage %float Buffer 0 0 0 2 Rgba32f
%ptr = OpTypePointer UniformConstant %rgba32f
%storageTexelBuffer = OpVariable %ptr UniformConstant
为纹理元素缓冲区使用非 RGBA 格式
使用纹理元素缓冲区时的一个常见错误,是忘记每次仅能访问单个纹理元素,且该纹理元素格式可包含 1 至 4 个分量(如R8和RGBA8)。部分着色器语言(如 GLSL)要求写入操作必须包含 4 个分量,超出的分量会被忽略。
// 对应VK_FORMAT_R32_UINT格式
layout(set = 0, binding = 0, r32ui) uniform uimageBuffer storageTexelBuffer;
void main() {
// GLSL中该写法无效,必须使用uvec4类型
uint a = 1;
imageStore(storageTexelBuffer, 0, a);
// 常见错误:认为该操作会将4个值写入4个连续的纹理元素
// 实际仅会写入值"1",其余3个分量会被丢弃,因为该格式仅包含1个分量
uvec4 b = uvec4(1, 2, 3, 4);
imageStore(storageTexelBuffer, 0, b);
}
纹理元素缓冲区的格式
并非所有格式都支持纹理元素缓冲区操作,VkFormatProperties中的VK_FORMAT_FEATURE_UNIFORM_TEXEL_BUFFER_BIT和VK_FORMAT_FEATURE_STORAGE_TEXEL_BUFFER_BIT标识,分别指示某一格式是否可用于统一纹理元素缓冲区和存储纹理元素缓冲区。
cpp
VkFormatProperties formatProperties;
vkGetPhysicalDeviceFormatProperties(physicalDevice, format, &formatProperties);
if (!(formatProperties.bufferFeatures & VK_FORMAT_FEATURE_UNIFORM_TEXEL_BUFFER_BIT)) {
// 该格式不支持统一纹理元素缓冲区操作
}
if (!(formatProperties.bufferFeatures & VK_FORMAT_FEATURE_STORAGE_TEXEL_BUFFER_BIT)) {
// 该格式不支持存储纹理元素缓冲区操作
}
上述代码使用VkFormatProperties的bufferFeatures成员检查纹理元素缓冲区的格式支持性,而图像的格式支持性检查则使用optimalTilingFeatures或linearTilingFeatures成员。
纹理元素缓冲区的同步操作
使用存储纹理元素缓冲区时,必须执行正确的同步操作以避免竞争条件。
cpp
VkBufferMemoryBarrier barrier = {};
barrier.sType = VK_STRUCTURE_TYPE_BUFFER_MEMORY_BARRIER;
barrier.srcAccessMask = VK_ACCESS_SHADER_WRITE_BIT;
barrier.dstAccessMask = VK_ACCESS_SHADER_READ_BIT;
barrier.srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED;
barrier.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED;
barrier.buffer = buffer;
barrier.offset = 0;
barrier.size = VK_WHOLE_SIZE;
vkCmdPipelineBarrier(
commandBuffer,
VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT,
VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT,
0,
0, nullptr,
1, &barrier,
0, nullptr
);
与其他缓冲区类型的对比
存储图像 vs 存储缓冲区
存储图像和存储缓冲区均支持着色器中的读写操作,但二者的适用场景不同:
- 存储图像:适用于二维或三维数据,这类数据能从滤波、寻址模式等纹理操作中获益。
- 存储缓冲区:更适合任意结构化数据,或需要以非均匀模式访问数据的场景。
纹理元素缓冲区 vs 存储缓冲区
纹理元素缓冲区和存储缓冲区也各有优势:
- 纹理元素缓冲区:为缓冲区数据提供类纹理的访问方式,支持滤波等操作。
- 存储缓冲区:在通用数据存储和处理方面灵活性更高。
基于分片的渲染器的注意事项
许多移动 GPU 和部分桌面 GPU 采用基于分片的渲染架构,该架构对存储图像和纹理元素缓冲区的使用方式有重要影响。
什么是基于分片的渲染?
在基于分片的渲染(TBR)或基于分片的延迟渲染(TBDR)中,GPU 将帧缓冲区划分为名为分片(tile) 的小型矩形区域,完成一个分片的所有处理(影响该分片的所有绘制调用)后,再处理下一个分片。该方式的优势:
- 将分片数据保存在高速片上内存中,减少内存带宽占用
- 提升电源效率,这对移动设备尤为重要
- 可高效实现特定的渲染技术
基于分片的渲染器中的存储图像
在基于分片的渲染器中使用存储图像,需注意以下几点:
- 分片内存刷新:向存储图像写入数据可能导致 GPU 将分片内存刷新至主存,降低基于分片渲染的优势,若频繁执行该操作,会对性能造成显著影响;建议对存储图像操作进行批处理,最大限度减少分片内存刷新。
- 临时附件 :部分基于分片的渲染器支持仅存在于分片内存中的特殊临时附件,这类附件因无底层主存,无法用作存储图像;若需要处理渲染结果,建议尽可能使用输入附件替代。
- 像素本地存储扩展 :部分基于分片的 GPU 提供
VK_EXT_shader_pixel_local_storage等扩展,为特定场景提供了比存储图像更高效的替代方案,这类扩展允许着色器访问保存在分片内存中的逐像素数据;在基于分片的硬件上,若该扩展可用,建议优先使用。 - 渲染通道一致性:在基于分片的渲染器中,某一渲染通道内写入存储图像的数据,可能无法被同一渲染通道中后续的绘制调用访问;需使用适当的内存屏障,或将处理任务拆分为多个渲染通道,同时注意这类内存在基于分片的渲染器上开销可能更高。
基于分片的渲染器中的纹理元素缓冲区
纹理元素缓冲区在基于分片的渲染器和立即模式渲染器中的工作方式大致相同,但仍有以下注意事项:
- 缓存一致性:基于分片的渲染器对纹理元素缓冲区的访问可能有不同的缓存行为;对纹理元素缓冲区进行读写时,需确保执行正确的同步操作,同时注意在基于分片的架构中,缓存刷新的开销可能更高。
- 内存访问模式:基于分片的渲染器对非一致性的内存访问模式更为敏感;建议组织数据以最大限度提高当前处理分片的局部性,设计算法时考虑分片大小。
基于分片的渲染器的性能优化
- 最大限度减少帧缓冲区解析 :每次将帧缓冲区内容作为存储图像访问时,基于分片的渲染器都必须将分片内存解析(写入) 至主存;建议在读取某一图像前,完成所有对该图像的修改操作;对于渲染通道内的操作,建议使用子通道和输入附件替代存储图像。
- 图像处理优先使用渲染通道而非计算着色器:在基于分片的渲染器中,渲染通道内的操作通常比使用存储图像的计算着色器更高效;建议将图像处理实现为渲染通道中的片元着色器,使用多个子通道将中间结果保存在分片内存中。
- 谨慎使用混合访问模式:在基于分片的渲染器中,对同一存储图像同时进行读写操作的开销可能极高;建议分离读取和写入阶段,考虑使用双缓冲技术避免写后读风险。
格式兼容性要求
使用存储图像和纹理元素缓冲区时,必须理解二者略有差异的格式兼容性规则,着色器格式与资源格式不匹配会导致未定义行为,并可能触发验证警告。
格式兼容性规则的差异
存储图像和纹理元素缓冲区的格式兼容性规则一致:
- 存储图像:着色器中指定的格式(SPIR-V 图像格式)必须与创建
VkImageView时使用的格式(Vulkan 格式)完全匹配。 - 纹理元素缓冲区:着色器中指定的格式(SPIR-V 图像格式)必须与创建
VkBufferView时使用的格式(Vulkan 格式)完全匹配。
两种资源类型均要求着色器与视图的格式完全匹配,视图格式必须始终与着色器格式保持一致。
SPIR-V 图像格式与 Vulkan 格式的兼容性
Vulkan 规范中定义了SPIR-V 图像格式与 Vulkan 格式的兼容性表,明确了二者之间的精确映射关系。
存储图像的格式要求
对于存储图像,根据兼容性表,着色器中指定的格式必须与图像视图的格式完全匹配,不存在自动格式转换或分量重排。
// SPIR-V格式Rgba8(映射至VK_FORMAT_R8G8B8A8_UNORM)
layout(set = 0, binding = 0, rgba8) uniform image2D storageImage;
// VkImageView必须使用VK_FORMAT_R8G8B8A8_UNORM格式创建
// 使用VK_FORMAT_B8G8R8A8_UNORM会导致未定义行为
纹理元素缓冲区的格式要求
与存储图像相同,根据 SPIR-V 图像格式与 Vulkan 格式的兼容性表,着色器中指定的格式必须与缓冲区视图的格式完全匹配,不存在自动格式转换或分量重排。
// 对于统一纹理元素缓冲区,着色器中无需指定格式
layout(set = 0, binding = 0) uniform textureBuffer uniformTexelBuffer;
// VkBufferView必须使用与着色器预期完全匹配的格式创建
// 例如,若着色器预期RGBA数据,则必须使用VK_FORMAT_R8G8B8A8_UNORM
对于存储纹理元素缓冲区,着色器中需指定格式,且该格式必须与VkBufferView使用的格式完全匹配:
// SPIR-V格式Rgba8(映射至VK_FORMAT_R8G8B8A8_UNORM)
layout(set = 0, binding = 0, rgba8) uniform imageBuffer storageTexelBuffer;
// VkBufferView必须使用VK_FORMAT_R8G8B8A8_UNORM格式创建
// 使用其他格式,即使属于同一兼容类,也会导致未定义行为
分量重排
存储图像和纹理元素缓冲区的分量重排规则相同:
- 存储图像:不存在自动分量重排,分量的访问方式与其在内存中的存储方式完全一致;若需要进行分量重排(如在 RGBA 和 BGRA 之间转换),必须在着色器代码中手动实现。
- 纹理元素缓冲区:与存储图像相同,不存在自动分量重排,分量的访问方式与其在内存中的存储方式完全一致;若需要进行分量重排,必须在着色器代码中手动实现。
图像视图 :对于采样图像(非存储图像),可在VkImageViewCreateInfo中使用VkComponentMapping结构体指定分量重排,该特性不适用于存储图像和纹理元素缓冲区。
常见的格式不匹配情况
多种格式不匹配的情况均会导致未定义行为,主要包括:
- 分量大小不匹配 :SPIR-V 格式的分量大小与 Vulkan 格式不一致。
- 示例:SPIR-V 格式
Rgba32f(32 位浮点分量)与VK_FORMAT_R8G8B8A8_UNORM(8 位分量) - 示例:SPIR-V 格式
R32ui(32 位无符号整型)与VK_FORMAT_R8_UINT(8 位无符号整型)------ 该情况无效,且不存在隐式位转换,会导致未定义行为。
- 示例:SPIR-V 格式
- 分量数量不匹配 :SPIR-V 格式的分量数量与 Vulkan 格式不一致。
- 写入分量过多:SPIR-V 格式
Rgba8(4 个分量)与VK_FORMAT_R8_UNORM(1 个分量) - 写入分量过少:SPIR-V 格式
R8(1 个分量)与VK_FORMAT_R8G8B8A8_UNORM(4 个分量)
- 写入分量过多:SPIR-V 格式
- 数值格式不匹配 :SPIR-V 格式的数值格式(归一化、浮点、整型)与 Vulkan 格式不一致。
- 示例:SPIR-V 格式
Rgba8(UNORM)与VK_FORMAT_R8G8B8A8_SNORM(SNORM)
- 示例:SPIR-V 格式
- 数值类型不匹配 :SPIR-V 格式的数值类型(浮点、有符号整型、无符号整型)与 Vulkan 格式不一致。
- 示例:SPIR-V 格式
R8(浮点)与VK_FORMAT_R8_SINT(有符号整型) - 示例:SPIR-V 格式
R8ui(无符号整型)与VK_FORMAT_R8_SINT(有符号整型)
- 示例:SPIR-V 格式
- 通道顺序不匹配 :SPIR-V 格式的通道顺序与 Vulkan 格式不一致。
- 示例:SPIR-V 格式
Rgba8(RGBA 顺序)与VK_FORMAT_B8G8R8A8_UNORM(BGRA 顺序) - 该情况对存储图像尤为不利,因为不存在自动分量重排。
- 示例:SPIR-V 格式
如何修复格式不匹配问题
根据资源类型的不同,修复格式不匹配问题的方法也有所差异:
对于存储图像
-
完全匹配格式 :确保
VkImageView格式与兼容性表中定义的 SPIR-V 图像格式完全匹配。例如,若着色器使用rgba8(SPIR-V 格式Rgba8),则使用VK_FORMAT_R8G8B8A8_UNORM创建VkImageView。若需要使用其他格式(如VK_FORMAT_B8G8R8A8_UNORM),需在着色器代码中手动进行分量重排:glsl
// 手动重排分量,实现BGRA到RGBA的转换 vec4 value = imageLoad(storageImage, texelCoord); vec4 swizzled = value.bgra; // 手动重排分量 // 使用重排后的数据 -
在 SPIR-V 中使用未知格式 :若需要灵活使用多种格式,可在 SPIR-V 中使用
Unknown格式,该格式与任意 Vulkan 格式兼容,使用该特性需启用shaderStorageImageWriteWithoutFormat功能。在 GLSL 中,该方式表现为省略格式限定符:// 未指定格式,在SPIR-V中使用Unknown格式 layout(set = 0, binding = 0) uniform image2D storageImage;注意,使用 Unknown 格式时,开发者需自行确保对图像的读写数据与图像的实际格式兼容。
对于纹理元素缓冲区
- 完全匹配格式 :与存储图像相同,确保
VkBufferView格式与兼容性表中定义的 SPIR-V 图像格式完全匹配。例如,若着色器使用rgba8(SPIR-V 格式Rgba8),则使用VK_FORMAT_R8G8B8A8_UNORM创建VkBufferView,使用其他格式(即使属于同一兼容类)也会导致未定义行为。 - 在着色器中处理分量重排:若需要使用不同通道顺序的格式(如 RGBA 和 BGRA),由于不存在自动分量重排,需在着色器代码中显式处理分量重排。
重要注意事项
- 当存储图像或纹理元素缓冲区出现格式不匹配时,整个内存的状态都会变为未定义,而非仅被写入的纹理元素。
- 即使是同一兼容类的格式(如
VK_FORMAT_R8G8B8A8_UNORM和VK_FORMAT_B8G8R8A8_UNORM),对于存储图像和纹理元素缓冲区,也必须完全匹配。 - 存储图像和纹理元素缓冲区遵循相同严格的格式兼容性规则 ------ 着色器中指定的格式必须与视图中使用的格式完全匹配。
- 格式不匹配的验证警告旨在帮助开发者发现潜在问题,因为这类不匹配可能导致难以直接发现的细微错误。
- 存储图像和纹理元素缓冲区均无自动分量重排,二者的分量重排均需手动实现。
最佳实践
性能考量
- 格式选择 :选择硬件原生支持的格式以获得更好的性能。
- 优先选择硬件原生支持的格式(检查
VkFormatProperties) - 对于存储图像,32 位格式(
R32_*)的性能通常优于打包格式 - 若仅需要单个通道,考虑使用单通道格式以减少内存带宽占用
- 优先选择硬件原生支持的格式(检查
- 内存访问模式 :对存储图像和纹理元素缓冲区进行读写时,尽量保证内存访问的合并性。
- 将内存访问分组至相邻地址,最大限度提高缓存效率
- 在计算着色器中,将工作组大小与硬件的 warp/wavefront 大小对齐
- 访问二维图像时,考虑其内存布局(行优先 vs 列优先)
- 对于纹理元素缓冲区,顺序访问的速度通常快于随机访问
- 同步操作 :使用最小必要的同步操作,避免性能损失。
- 使用尽可能具体的访问标识和管线阶段
- 对操作进行批处理,减少所需的内存屏障数量
- 仅在绝对必要时使用
VK_PIPELINE_STAGE_ALL_COMMANDS_BIT - 对于计算工作负载,尽量设计能最大限度减少同步点的算法
- 资源复用 :尽可能复用存储图像和纹理元素缓冲区,减少内存分配开销。
- 考虑为频繁创建 / 销毁的资源实现资源池
- 对每帧更新的资源,使用双缓冲或三缓冲技术
- 工作负载均衡 :将工作负载均匀分配至计算着色器的调用实例。
- 根据硬件选择合适的工作组大小(通常为 32 或 64 的倍数)
- 避免工作组内出现分支执行路径
- 对于大尺寸图像,考虑使用分块处理以提高缓存局部性
常见陷阱
- 格式支持性 :并非所有格式都支持存储操作,需始终检查格式特性。
- 创建资源前,使用
vkGetPhysicalDeviceFormatProperties验证格式支持性 - 部分格式可能支持存储操作,但性能会有所降低
- 注意不同硬件厂商的格式支持性可能存在差异
- 创建资源前,使用
- 内存屏障 :缺失或错误的内存屏障会导致竞争条件和未定义行为。
- 写入操作和后续的读取操作之间,始终使用适当的内存屏障
- 记住,即使操作在同一个着色器中,也需要使用内存屏障
- 对于计算着色器,在 GLSL 中适当使用
memoryBarrierImage()或memoryBarrierBuffer() - 对访问同一资源的多队列提交操作保持谨慎
- 布局转换 :存储图像通常使用
VK_IMAGE_LAYOUT_GENERAL布局,但仍需执行至该布局的转换操作。- 使用图像前,始终将其转换至正确的布局
- 注意
VK_IMAGE_LAYOUT_GENERAL的效率可能低于专用布局 - 若仅需要只读访问,考虑使用
VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL
- 原子操作 :对存储图像和缓冲区执行原子操作的开销较高。
- 尽可能减少原子操作的使用
- 考虑使用无需原子操作的替代算法
- 注意不同硬件厂商的原子操作性能差异显著
- 对原子操作进行分组,最大限度减少内存竞争
- 资源限制 :注意设备对存储图像和纹理元素缓冲区的资源限制。
- 检查
maxPerStageDescriptorStorageImages及相关限制 - 部分设备对可写入的存储资源数量可能存在限制
- 使用大量存储资源时,考虑其对描述符集布局的影响
- 检查
- 验证层 :开发过程中使用验证层,发现常见错误。
- 启用同步验证,检测内存屏障问题
- 关注格式支持性和使用标识的警告
- 尽可能在多个硬件厂商的设备上测试,发现实现相关的问题
- 着色器编译 :注意着色器编译的潜在影响。
- 复杂的存储图像和纹理元素缓冲区操作可能增加寄存器压力
- 考虑将复杂的着色器拆分为多个渲染通道
- 对着色器性能进行分析,找出性能瓶颈
示例用例
- 基于存储图像的图像处理:存储图像非常适合滤镜、模糊等图像处理任务和其他后处理效果。
- 基于存储纹理元素缓冲区的粒子系统:可在计算着色器中使用存储纹理元素缓冲区存储和更新粒子数据,再由顶点着色器读取数据进行渲染。
- 基于统一纹理元素缓冲区的查找表:统一纹理元素缓冲区可用于实现需要通过类纹理操作访问的查找表。