Compute Shader在cluster上的应用
设计思路
1 目标:
- 在 GPU 上并行计算每个光源对集群的分配,生成与 CPU 版本等效的 ClusterCountsandOffsets 和 ClusterIndexList 数据结构。
- 使用共享内存减少全局内存访问,提高性能。
使用原子操作确保线程安全地更新集群计数和索引列表。
2 工作组划分:
- 每个工作组处理一个光源,计算其影响的集群。
- 工作组内的线程协同工作,通过共享内存缓存中间数据。
- 最终将结果写入全局缓冲区。
3 共享内存使用:
- 每个工作组使用共享内存存储临时集群计数和偏移。
- 共享内存大小基于 TOTAL_CLUSTERS(888),每个集群需要存储光源计数。
- 原子操作用于更新共享内存中的计数,确保线程安全。
4 原子操作:
- 使用 InterlockedAdd(HLSL 的原子加法)更新共享内存中的集群计数。
- 使用全局原子计数器为每个集群分配唯一的偏移。
5 输出:
- 生成 ClusterCountsandOffsets(每个集群的计数和偏移)。
- 生成 ClusterIndexList(每个集群的光源索引列表)。
基于以上分析,需要的hlsl代码实现为
// Compute Shader for Clustered Lighting
// 基于共享内存和原子操作的 GPU 集群光照实现
#define NUM_LIGHTS 64
#define CLUSTER_SIZE_X 8
#define CLUSTER_SIZE_Y 8
#define CLUSTER_SIZE_Z 8
#define TOTAL_CLUSTERS (CLUSTER_SIZE_X * CLUSTER_SIZE_Y * CLUSTER_SIZE_Z)
#define LIGHT_INDEX_LIST_SIZE (NUM_LIGHTS * TOTAL_CLUSTERS)
// 光源结构体,与片段着色器一致
struct Light {
float4 position;
float4 colorAndRadius;
float4 direction;
float4 cutOff;
};
// 集群计数和偏移结构体
struct Cluster {
uint counts;
uint offsets;
float2 padding; // 确保 16 字节对齐
};
// 光源索引列表结构体
struct ClusterIndexList {
uint clusterIndexList;
float padding1;
float padding2;
float padding3; // 确保 16 字节对齐
};
// Uniform Buffer,包含相机矩阵
struct UBO {
float4x4 projection;
float4x4 model;
float4x4 view;
float3 camPos;
uint maxlightindexnum;
};
// 输入缓冲区
cbuffer ubo : register(b0) {
UBO ubo;
};
StructuredBuffer<Light> lights : register(t0); // 光源数据
RWStructuredBuffer<Cluster> clusterCountsandOffsets : register(u0); // 读写集群计数和偏移
RWStructuredBuffer<ClusterIndexList> clusterIndexList : register(u1); // 读写光源索引列表
RWBuffer<uint> globalCounter : register(u2); // 全局原子计数器,用于分配偏移
// 共享内存,存储每个集群的临时计数
groupshared uint sharedClusterCounts[TOTAL_CLUSTERS];
// 工作组大小
#define WORKGROUP_SIZE_X 8
#define WORKGROUP_SIZE_Y 8
#define WORKGROUP_SIZE_Z 1
#define TOTAL_THREADS (WORKGROUP_SIZE_X * WORKGROUP_SIZE_Y * WORKGROUP_SIZE_Z)
// Compute Shader 主函数
[numthreads(WORKGROUP_SIZE_X, WORKGROUP_SIZE_Y, WORKGROUP_SIZE_Z)]
void main(uint3 groupId : SV_GroupID, uint3 threadId : SV_GroupThreadID, uint3 dispatchId : SV_DispatchThreadID) {
// 初始化共享内存
if (threadId.x == 0 && threadId.y == 0 && threadId.z == 0) {
for (uint i = 0; i < TOTAL_CLUSTERS; i++) {
sharedClusterCounts[i] = 0;
}
}
GroupMemoryBarrierWithGroupSync(); // 确保共享内存初始化完成
// 每个工作组处理一个光源
uint lightIdx = groupId.x;
if (lightIdx >= NUM_LIGHTS) return; // 超出光源数量,直接返回
// 获取光源数据
Light light = lights[lightIdx];
float radius = light.colorAndRadius.w;
// 计算光源在裁剪空间的位置
float4x4 viewProj = mul(ubo.projection, ubo.view);
float4 clipPos = mul(viewProj, light.position);
float ndcX = clipPos.x / clipPos.w;
float ndcY = clipPos.y / clipPos.w;
float ndcZ = clipPos.z / clipPos.w;
float radiusNDC = radius / clipPos.w;
// 计算影响的集群范围
float zNear = 0.1;
float zFar = 256.0;
float minX = clamp(ndcX - radiusNDC, -1.0, 1.0);
float maxX = clamp(ndcX + radiusNDC, -1.0, 1.0);
float minY = clamp(ndcY - radiusNDC, -1.0, 1.0);
float maxY = clamp(ndcY + radiusNDC, -1.0, 1.0);
float minZ = clamp(ndcZ - radiusNDC, 0.0, 1.0);
float maxZ = clamp(ndcZ + radiusNDC, 0.0, 1.0);
// 映射到集群索引
uint minClusterX = uint((minX * 0.5 + 0.5) * CLUSTER_SIZE_X);
uint maxClusterX = uint((maxX * 0.5 + 0.5) * CLUSTER_SIZE_X);
uint minClusterY = uint((minY * 0.5 + 0.5) * CLUSTER_SIZE_Y);
uint maxClusterY = uint((maxY * 0.5 + 0.5) * CLUSTER_SIZE_Y);
uint minClusterZ = uint((log(max(0.0001, minZ) * (zFar - zNear) + zNear) / log(zFar / zNear)) * CLUSTER_SIZE_Z);
uint maxClusterZ = uint((log(max(0.0001, maxZ) * (zFar - zNear) + zNear) / log(zFar / zNear)) * CLUSTER_SIZE_Z);
minClusterX = clamp(minClusterX, 0u, CLUSTER_SIZE_X - 1);
maxClusterX = clamp(maxClusterX, 0u, CLUSTER_SIZE_X - 1);
minClusterY = clamp(minClusterY, 0u, CLUSTER_SIZE_Y - 1);
maxClusterY = clamp(maxClusterY, 0u, CLUSTER_SIZE_Y - 1);
minClusterZ = clamp(minClusterZ, 0u, CLUSTER_SIZE_Z - 1);
maxClusterZ = clamp(maxClusterZ, 0u, CLUSTER_SIZE_Z - 1);
// 每个线程处理一个集群子集
uint threadIdx = threadId.x + threadId.y * WORKGROUP_SIZE_X + threadId.z * WORKGROUP_SIZE_X * WORKGROUP_SIZE_Y;
uint clusterCountX = maxClusterX - minClusterX + 1;
uint clusterCountY = maxClusterY - minClusterY + 1;
uint clusterCountZ = maxClusterZ - minClusterZ + 1;
uint totalClusters = clusterCountX * clusterCountY * clusterCountZ;
for (uint i = threadIdx; i < totalClusters; i += TOTAL_THREADS) {
// 计算集群坐标
uint z = i / (clusterCountX * clusterCountY);
uint y = (i / clusterCountX) % clusterCountY;
uint x = i % clusterCountX;
z += minClusterZ;
y += minClusterY;
x += minClusterX;
uint clusterIdx = z * CLUSTER_SIZE_X * CLUSTER_SIZE_Y + y * CLUSTER_SIZE_X + x;
if (clusterIdx < TOTAL_CLUSTERS) {
// 使用原子操作增加共享内存中的计数
InterlockedAdd(sharedClusterCounts[clusterIdx], 1);
}
}
GroupMemoryBarrierWithGroupSync(); // 等待所有线程完成计数
// 主线程将共享内存的计数写入全局缓冲区,并分配偏移
if (threadIdx == 0) {
uint runningSum = 0;
// 使用原子操作获取全局偏移
InterlockedAdd(globalCounter[0], 0, runningSum); // 获取当前全局计数
for (uint i = 0; i < TOTAL_CLUSTERS; i++) {
if (sharedClusterCounts[i] > 0) {
clusterCountsandOffsets[i].counts = sharedClusterCounts[i];
clusterCountsandOffsets[i].offsets = runningSum;
InterlockedAdd(globalCounter[0], sharedClusterCounts[i]); // 更新全局计数
runningSum += sharedClusterCounts[i];
} else {
clusterCountsandOffsets[i].counts = 0;
clusterCountsandOffsets[i].offsets = runningSum;
}
}
}
GroupMemoryBarrierWithGroupSync(); // 确保偏移分配完成
// 再次遍历,将光源索引写入全局索引列表
for (uint i = threadIdx; i < totalClusters; i += TOTAL_THREADS) {
uint z = i / (clusterCountX * clusterCountY);
uint y = (i / clusterCountX) % clusterCountY;
uint x = i % clusterCountX;
z += minClusterZ;
y += minClusterY;
x += minClusterX;
uint clusterIdx = z * CLUSTER_SIZE_X * CLUSTER_SIZE_Y + y * CLUSTER_SIZE_X + x;
if (clusterIdx < TOTAL_CLUSTERS && sharedClusterCounts[clusterIdx] > 0) {
// 使用原子操作获取当前集群的偏移
uint offset;
InterlockedAdd(clusterCountsandOffsets[clusterIdx].counts, -1, offset); // 递减计数,获取可用槽
offset = clusterCountsandOffsets[clusterIdx].offsets + offset - 1; // 计算实际偏移
if (offset < LIGHT_INDEX_LIST_SIZE) {
clusterIndexList[offset].clusterIndexList = lightIdx;
}
}
}
}
代码说明
1 输入和输出缓冲区:
输入:
lights(光源数据,StructuredBuffer)、ubo(相机矩阵,Constant Buffer)。
输出:
clusterCountsandOffsets(RWStructuredBuffer,存储集群计数和偏移)、clusterIndexList(RWStructuredBuffer,存储光源索引列表)、globalCounter(RWBuffer,单 uint 用于全局原子计数)。
缓冲区绑定与片段着色器一致,寄存器调整为 Compute Shader(t0, u0, u1, u2)。
2共享内存:
- sharedClusterCounts 是一个大小为 TOTAL_CLUSTERS(64)的数组,存储每个集群的临时光源计数。
- 每个工作组初始化共享内存为 0,确保无旧数据。
3工作组划分:
- 工作组大小为 [8, 8, 1],共 64 个线程,与集群网格尺寸匹配。
- 每个工作组处理一个光源(通过 groupId.x 获取 lightIdx)。
- 线程并行处理光源影响的集群子集。
4 集群分配:
- 计算光源在 NDC 空间的 AABB(轴对齐包围盒),与 CPU 版本的 updateLightsCluster 逻辑一致。
- 每个线程处理部分集群,使用原子操作 (InterlockedAdd) 更新共享内存中的计数。
5偏移分配:
- 主线程(threadIdx == 0)将共享内存的计数写入全局 clusterCountsandOffsets。
- 使用全局原子计数器 globalCounter[0] 分配偏移,确保各工作组的偏移不冲突。
6 索引列表填充:
- 再次遍历影响的集群,使用原子操作递减 counts 获取可用偏移。
- 将光源索引写入 clusterIndexList 的对应位置。
7 同步:
- 使用 GroupMemoryBarrierWithGroupSync 确保共享内存操作的线程安全。
- 全局原子操作 (InterlockedAdd) 确保跨工作组的偏移分配正确。
Vulkan 侧修改
为了支持 Compute Shader,需要在 C++ 代码中做以下调整:
1 创建 Compute Pipeline:
- 创建 VkPipeline 和 VkPipelineLayout 用于 Compute Shader。
- 使用 loadShader 加载编译后的 Compute Shader(例如 cluster.comp.hlsl)。
// 创建计算管线布局信息结构体
// 管线布局定义了计算着色器可以访问的资源(如描述符集)
VkPipelineLayoutCreateInfo computePipelineLayoutCI = vks::initializers::pipelineLayoutCreateInfo(&descriptorSetLayout, 1);
// 参数说明:
// - &descriptorSetLayout: 指向描述符集布局的指针,定义了着色器可以访问的资源类型和绑定点
// - 1: 描述符集布局的数量,这里只有一个描述符集布局
// 创建计算管线布局对象
// 管线布局是一个不可变对象,描述了管线可以访问的所有资源
VK_CHECK_RESULT(vkCreatePipelineLayout(device, &computePipelineLayoutCI, nullptr, &computePipelineLayout));
// 参数说明:
// - device: 逻辑设备句柄
// - &computePipelineLayoutCI: 指向创建信息结构体的指针
// - nullptr: 自定义内存分配器(这里使用默认分配器)
// - &computePipelineLayout: 输出参数,存储创建的管线布局句柄
// 创建计算管线创建信息结构体
// 初始化计算管线的基本信息,包括要使用的管线布局
VkComputePipelineCreateInfo computePipelineCI = vks::initializers::pipelineCreateInfo(computePipelineLayout);
// 参数说明:
// - computePipelineLayout: 之前创建的管线布局,定义了管线可以访问的资源
// 加载并设置计算着色器阶段
// 从文件加载HLSL计算着色器并编译为SPIR-V格式
computePipelineCI.stage = loadShader(getShadersPath() + "pbrbasic/cluster.comp.hlsl", VK_SHADER_STAGE_COMPUTE_BIT);
// 参数说明:
// - getShadersPath() + "pbrbasic/cluster.comp.hlsl": 着色器文件的完整路径
// - VK_SHADER_STAGE_COMPUTE_BIT: 指定这是一个计算着色器阶段
// loadShader函数会:
// 1. 读取HLSL源代码文件
// 2. 将其编译为SPIR-V字节码
// 3. 创建VkShaderModule对象
// 4. 返回VkPipelineShaderStageCreateInfo结构体
// 创建计算管线对象
// 使用配置好的创建信息结构体创建最终的计算管线
VK_CHECK_RESULT(vkCreateComputePipelines(device, pipelineCache, 1, &computePipelineCI, nullptr, &computePipeline));
// 参数说明:
// - device: 逻辑设备句柄
// - pipelineCache: 管线缓存对象,用于加速管线创建和复用已编译的着色器
// - 1: 要创建的计算管线数量
// - &computePipelineCI: 指向计算管线创建信息的指针
// - nullptr: 自定义内存分配器(使用默认分配器)
// - &computePipeline: 输出参数,存储创建的计算管线句柄
/*
整个流程总结:
1. 创建管线布局 - 定义计算着色器可以访问的资源
2. 配置管线创建信息 - 设置管线的基本属性
3. 加载计算着色器 - 从文件加载并编译着色器代码
4. 创建计算管线 - 生成最终可用的计算管线对象
这个计算管线创建完成后,可以在命令缓冲区中使用vkCmdBindPipeline绑定,
然后通过vkCmdDispatch或vkCmdDispatchIndirect执行计算操作。
*/
2 描述符集调整:
添加 globalCounter 缓冲区的绑定:
// 声明全局计数器缓冲区对象
// 这是一个存储缓冲区,用于在计算着色器中进行原子计数操作
vks::Buffer globalCounter;
// 创建全局计数器缓冲区
// 这个缓冲区通常用于计算着色器中的原子操作,如光照集群计数等
VK_CHECK_RESULT(vulkanDevice->createBuffer(
VK_BUFFER_USAGE_STORAGE_BUFFER_BIT, // 缓冲区用途标志
VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | VK_MEMORY_PROPERTY_HOST_COHERENT_BIT, // 内存属性标志
&globalCounter, // 输出的缓冲区对象
sizeof(uint))); // 缓冲区大小(一个uint的大小)
// 参数详细说明:
// VK_BUFFER_USAGE_STORAGE_BUFFER_BIT:
// - 指定这是一个存储缓冲区,可以在着色器中进行读写操作
// - 支持原子操作,适合用作计数器
//
// VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT:
// - 使缓冲区内存对CPU可见,可以直接从CPU访问
// - 允许CPU和GPU之间的数据传输
//
// VK_MEMORY_PROPERTY_HOST_COHERENT_BIT:
// - 确保CPU和GPU之间的内存一致性
// - 无需手动刷新缓存,CPU写入的数据GPU立即可见
//
// sizeof(uint):
// - 分配4字节空间存储一个无符号整数
// - 通常用于存储光照索引计数或其他全局计数值
// 将缓冲区内存映射到CPU地址空间
// 映射后可以直接通过CPU指针访问缓冲区数据
VK_CHECK_RESULT(globalCounter.map());
// 功能说明:
// - 创建CPU可访问的内存映射
// - 映射后globalCounter.mapped指向缓冲区数据
// - 可以直接通过*(uint*)globalCounter.mapped访问计数值
// - 由于设置了HOST_COHERENT_BIT,CPU修改会立即对GPU可见
// ========== 在 setupDescriptors 函数中添加描述符绑定 ==========
// 添加存储缓冲区的描述符集布局绑定
// 定义全局计数器在着色器中的绑定位置和访问方式
setLayoutBindings.push_back(vks::initializers::descriptorSetLayoutBinding(
VK_DESCRIPTOR_TYPE_STORAGE_BUFFER, // 描述符类型:存储缓冲区
VK_SHADER_STAGE_COMPUTE_BIT, // 着色器阶段:计算着色器
4)); // 绑定点:binding = 4
// 参数详细说明:
// VK_DESCRIPTOR_TYPE_STORAGE_BUFFER:
// - 指定这是一个存储缓冲区描述符
// - 在计算着色器中可以进行读写和原子操作
// - 对应HLSL中的RWBuffer或RWStructuredBuffer
//
// VK_SHADER_STAGE_COMPUTE_BIT:
// - 指定此资源只在计算着色器阶段使用
// - 限制资源的可见性,提高性能
//
// 4:
// - 绑定点索引,对应着色器中的layout(binding = 4)
// - 必须与着色器代码中的绑定点匹配
// 创建描述符集写入操作
// 将实际的缓冲区对象绑定到描述符集的指定位置
writeDescriptorSets.push_back(vks::initializers::writeDescriptorSet(
descriptorSet, // 目标描述符集
VK_DESCRIPTOR_TYPE_STORAGE_BUFFER, // 描述符类型
4, // 绑定点
&globalCounter.descriptor)); // 缓冲区描述符信息
// 参数详细说明:
// descriptorSet:
// - 要更新的描述符集对象
// - 包含了所有着色器资源的绑定信息
//
// VK_DESCRIPTOR_TYPE_STORAGE_BUFFER:
// - 必须与布局绑定中的类型匹配
// - 确保描述符类型一致性
//
// 4:
// - 绑定点索引,必须与布局绑定中的索引匹配
// - 着色器将在此位置访问全局计数器
//
// &globalCounter.descriptor:
// - 指向缓冲区描述符的指针
// - 包含缓冲区句柄、偏移量和大小信息
// - 告诉Vulkan具体绑定哪个缓冲区
/*
使用场景和典型用法:
1. 在计算着色器中的声明(HLSL):
RWBuffer<uint> globalCounter : register(u4);
2. 原子操作示例:
uint index = globalCounter.InterlockedAdd(0, 1);
3. 常见应用:
- 光照集群计数
- 可见性剔除计数
- 动态索引分配
- 统计信息收集
4. 注意事项:
- 需要在每帧开始时重置计数器值
- 原子操作有性能开销,应谨慎使用
- HOST_COHERENT内存类型性能较低,但便于调试
*/
3 命令缓冲区:
在 buildCommandBuffers 之前,添加 Compute Shader 的调度:
void dispatchCompute() {
VkCommandBufferBeginInfo cmdBufInfo = vks::initializers::commandBufferBeginInfo();
VK_CHECK_RESULT(vkBeginCommandBuffer(computeCmdBuffer, &cmdBufInfo));
vkCmdBindPipeline(computeCmdBuffer, VK_PIPELINE_BIND_POINT_COMPUTE, computePipeline);
vkCmdBindDescriptorSets(computeCmdBuffer, VK_PIPELINE_BIND_POINT_COMPUTE, computePipelineLayout, 0, 1, &descriptorSet, 0, nullptr);
vkCmdDispatch(computeCmdBuffer, NUM_LIGHTS, 1, 1); // 每个光源一个工作组
VK_CHECK_RESULT(vkEndCommandBuffer(computeCmdBuffer));
VkSubmitInfo submitInfo = vks::initializers::submitInfo();
submitInfo.commandBufferCount = 1;
submitInfo.pCommandBuffers = &computeCmdBuffer;
VK_CHECK_RESULT(vkQueueSubmit(queue, 1, &submitInfo, VK_NULL_HANDLE));
vkQueueWaitIdle(queue);
}
## 4 初始化全局计数器:
在 updateLightsCluster 或新的 Compute Shader 调度函数中,初始化 globalCounter:
uint32_t zero = 0;
memcpy(globalCounter.mapped, &zero, sizeof(uint));
TIPS
1 工作组大小
可以将工作组大小该为 8x8x8(512 线程)以与集群数量一致,可以修改 Compute Shader,但需要考虑以下因素:
优点:
每个工作组的线程数量(512)与集群数量(512)完全匹配,可能简化某些集群分配逻辑。
缺点:
大多数光源只影响少量集群(例如,10-50 个),512 个线程会导致大量线程空闲,降低 GPU 利用率。
增加线程数可能超过某些 GPU 的工作组大小限制(例如,NVIDIA 最大 1024 线程,但 AMD 可能限制更低)。
共享内存使用量会增加(sharedClusterCounts[512] 仍可行,但需检查 GPU 共享内存限制,典型为 48KB)。
2 vks::initializers::pipelineCreateInfo 函数调用存在问题
VkComputePipelineCreateInfo computePipelineCI = vks::initializers::pipelineCreateInfo(computePipelineLayout);
(1) 错误说明
"vks::initializers::pipelineCreateInfo": 没有重载函数接受 1 个参数
这表明 pipelineCreateInfo 函数被调用时只传递了一个参数(可能是 pipelineLayout),但函数签名需要更多参数,或者没有匹配的重载版本。
- 错误 2:"初始化": 无法从“VkGraphicsPipelineCreateInfo”转换为“VkComputePipelineCreateInfo”
- 这表明在创建计算管线(Compute Pipeline)时,vks::initializers::pipelineCreateInfo 返回了 VkGraphicsPipelineCreateInfo 类型,但计算管线需要 VkComputePipelineCreateInfo。
问题可能源于 VulkanExampleBase 或 Sascha Willems 示例代码中的 vks::initializers 工具函数定义。
(2) 问题分析
在 Sascha Willems 的 Vulkan 示例中,vks::initializers::pipelineCreateInfo 通常用于创建图形管线的 VkGraphicsPipelineCreateInfo 结构,典型签名如下:
VkGraphicsPipelineCreateInfo pipelineCreateInfo(VkPipelineLayout layout, VkRenderPass renderPass, uint32_t subpass = 0);
它需要 layout(管线布局)、renderPass(渲染通道)和可选的 subpass 参数
VkComputePipelineCreateInfo computePipelineCI = vks::initializers::pipelineCreateInfo(computePipelineLayout);
但 vks::initializers::pipelineCreateInfo 可能没有为 VkComputePipelineCreateInfo 定义仅接受 VkPipelineLayout 的重载版本,导致“没有重载函数接受 1 个参数”错误。
此外,计算管线不需要 renderPass 或 subpass,因此需要一个专门的初始化函数。
另外
vks::initializers::pipelineCreateInfo 返回 VkGraphicsPipelineCreateInfo,但 vkCreateComputePipelines 期望 VkComputePipelineCreateInfo。这导致类型不匹配错误。
3 comp文件创建
由于在VS中创建的comp文件可能无效,需要将文件在原始目录的shader/hlhl/下创建新的文件进行编译处理
4 着色器绑定
C++ 代码中的 VkWriteDescriptorSet 绑定与着色器绑定的对应关系如下:
绑定点 0:uniformBuffers.object(UBO) → register(b0)
绑定点 1:uniformBuffers.params(UBO,Lights) → register(b1)
绑定点 2:uniformBuffers.clusterIndexList(存储缓冲) → register(u2)
绑定点 3:uniformBuffers.clusterData(存储缓冲) → register(u3)
绑定点 4:uniformBuffers.globalCounter(存储缓冲) → register(u4)

浙公网安备 33010602011771号