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)

posted @ 2025-07-22 14:56  BlueTOberry  阅读(50)  评论(0)    收藏  举报