贝博恩创新科技网

Compute Shader如何入门与实战应用?

Compute Shader 完整教程:从入门到精通

目录

  1. 第一部分:核心概念

    Compute Shader如何入门与实战应用?-图1
    (图片来源网络,侵删)
    • 1 什么是 Compute Shader?
    • 2 为什么需要 Compute Shader?(优势)
    • 3 Compute Shader 与传统渲染流水线的区别
    • 4 关键概念:线程、线程组、线程组ID
  2. 第二部分:HLSL 基础语法

    • 1 结构体与数据类型
    • 2 内置变量(numthreads, SV_DispatchThreadID, SV_GroupThreadID, SV_GroupID
    • 3 核心函数:numthreadskernel
  3. 第三部分:Unity 中的 Compute Shader 资源

    • 1 创建 Compute Shader 文件
    • 2 ComputeShader 类与 C# 脚本的交互
    • 3 关键资源类型:ComputeBuffer
    • 4 资源绑定与 ComputeShader.Set... 方法
  4. 第四部分:第一个完整示例:并行加法

    • 1 C# 脚本:准备数据与调度计算
    • 2 Compute Shader:编写核心计算逻辑
    • 3 运行与结果验证
  5. 第五部分:进阶示例:图像模糊处理

    Compute Shader如何入门与实战应用?-图2
    (图片来源网络,侵删)
    • 1 理解图像纹理在 Compute Shader 中的访问
    • 2 使用 RWTexture2D
    • 3 编写模糊算法
    • 4 C# 脚本中的纹理设置与调度
  6. 第六部分:性能优化与最佳实践

    • 1 内存带宽瓶颈
    • 2 内存合并访问
    • 3 线程组大小的选择
    • 4 同步操作:GroupMemoryBarrier()
    • 5 资源的生命周期管理

第一部分:核心概念

1 什么是 Compute Shader?

Compute Shader(计算着色器)是一种运行在 GPU 上的、用于通用并行计算的着色器程序,它不参与传统的渲染流水线(顶点处理、光栅化、片段着色),而是像一个独立的“微型处理器”,专门用来执行大规模的并行计算任务。

你可以把它想象成一种专门为 GPU 设计的“函数”,这个函数可以被成千上万个核心同时执行。

2 为什么需要 Compute Shader?(优势)

  • 极致的并行性:GPU 拥有数千个计算核心,非常适合处理“数据密集型”和“高度并行”的任务,对数百万个粒子进行物理模拟、处理大型数据集、进行科学计算等。
  • 高吞吐量:相比 CPU,GPU 能以更低的延迟处理海量数据。
  • 释放 CPU:将复杂的计算任务 offload 到 GPU,让 CPU 可以专注于游戏逻辑、AI、物理等其他任务,从而提升整体帧率和流畅度。

3 Compute Shader 与传统渲染流水线的区别

特性 顶点/片段着色器 Compute Shader
主要用途 渲染图形 通用计算
输入数据 顶点数据、纹理、插值后的变量 任意 BufferTexture
输出数据 渲染到屏幕或 Render Target 写入 BufferTexture
执行流程 固定流水线(顶点->光栅化->片段) 灵活的“内核”调度
访问控制 只能读/写特定资源 可以读写任意绑定的资源

4 关键概念:线程、线程组、线程组ID

这是理解 Compute Shader 工作方式的核心。

Compute Shader如何入门与实战应用?-图3
(图片来源网络,侵删)
  • Thread(线程):Compute Shader 中的基本执行单元,一个线程就是一次 kernel 函数的一次调用,GPU 中的成千上万个核心同时执行成千上万个线程。
  • Thread Group(线程组):GPU 硬件在调度时,不会一次性启动所有线程,而是以“组”为单位,一个线程组内的线程会被安排在同一个 GPU 处理簇上,可以高效地共享资源(如共享内存)。
  • Dispatch(调度):从 C# 脚本中发起计算任务的过程,你告诉 GPU:“请启动 X 个线程组,每个线程组包含 Y 个线程”。
  • ID 变量
    • SV_DispatchThreadID:全局唯一的线程 ID,范围从 (0, 0, 0)(totalThreadsX-1, ...),这是最常用的 ID,用于定位全局数据。
    • SV_GroupThreadID:线程在其所属的线程组内的本地 ID,范围从 (0, 0, 0)(numthreads.x-1, ...)
    • SV_GroupID:线程组的 ID,范围从 (0, 0, 0)(groupCountX-1, ...)

形象比喻: 想象一个巨大的电影院(所有线程)。

  • SV_DispatchThreadID 是你的座位号(15排8座),是唯一的。
  • SV_GroupID 是你所在的影厅号(3号厅)。
  • SV_GroupThreadID 是你在影厅内的座位号(5排3座)。

第二部分:HLSL 基础语法

Compute Shader 使用 HLSL(High-Level Shading Language)编写,与 Unity 的 ShaderLab 中的 HLSL 基本一致。

1 结构体与数据类型

// 定义一个结构体,用于在 C# 和 Shader 之间传递数据
struct MyData {
    float3 position;
    float velocity;
};
// 常用数据类型
int, uint, float, double
float2, float3, float4 // 向量
int3, uint3 // 整数向量
float4x4   // 矩阵
StructuredBuffer<float> // 只读缓冲区
RWStructuredBuffer<float> // 可读写缓冲区

2 内置变量

这些是编译器自动提供的变量,用于获取线程信息。

  • numthreads(x, y, z):一个 关键字,用于定义一个线程组包含多少个线程。numthreads(8, 8, 1) 表示每个线程组有 8x8=64 个线程。x, y, z 的乘积通常是 64 的倍数(如 64, 128, 256, 512)以获得最佳性能。
  • SV_DispatchThreadID:获取全局线程 ID。
  • SV_GroupThreadID:获取线程组内的本地线程 ID。
  • SV_GroupID:获取线程组 ID。

3 核心函数:numthreadskernel

  • numthreads:在 kernel 函数前定义,如上所述。
  • kernel:一个 C# 可调用的函数,一个 Compute Shader 文件可以包含多个 kernel
// 定义一个名为 MyKernel 的计算核心
[numthreads(8, 8, 1)]
kernel void MyKernel (RWStructuredBuffer<float> outputBuffer)
{
    // 获取当前线程的全局ID
    uint id = SV_DispatchThreadID.x;
    // 执行计算...
    outputBuffer[id] = id * id; 
}

第三部分:Unity 中的 Compute Shader 资源

1 创建 Compute Shader 文件

在 Unity 的 Project 窗口中,右键 -> Create -> Shader -> Compute Shader。

2 ComputeShader 类与 C# 脚本的交互

C# 脚本是 Compute Shader 的“指挥官”,它负责:

  1. 加载 .compute 文件。
  2. 创建和管理数据资源(主要是 ComputeBuffer)。
  3. 将数据资源绑定到 Shader 的特定变量槽。
  4. 调度 kernel 执行。
using UnityEngine;
public class MyComputeController : MonoBehaviour
{
    public ComputeShader myComputeShader;
    // ... 其他代码
}

3 关键资源类型:ComputeBuffer

ComputeBuffer 是 GPU 和 CPU 之间交换数据的核心桥梁,它是一块连续的内存,可以在 C# 中创建,然后传递给 Compute Shader 进行读写。

创建和释放:

// 创建一个包含 1000 个 float 的缓冲区
ComputeBuffer myBuffer = new ComputeBuffer(1000, sizeof(float));
// 使用完后一定要释放!
myBuffer.Release();

构造函数参数:count(元素数量)和 stride(每个元素的大小,通过 sizeof(type) 获取)。

4 资源绑定与 ComputeShader.Set... 方法

在 C# 中,你需要将数据告诉 GPU,这通过一系列 Set 方法完成。

  1. 设置 Kernel

    int kernelHandle = myComputeShader.FindKernel("MyKernel");
  2. 绑定 Buffer

    // 将 C# 创建的 myBuffer 绑定到 Shader 中名为 "OutputBuffer" 的变量
    myComputeShader.SetBuffer(kernelHandle, "OutputBuffer", myBuffer);

    Shader 中的变量需要用 [numthreads(...)] 修饰,并且通常是 RWStructuredBufferStructuredBuffer

  3. 绑定 Texture

    // 绑定一个可读写的 2D 纹理
    myComputeShader.SetTexture(kernelHandle, "ResultTexture", myRenderTexture);

    Shader 中的变量需要是 RWTexture2D<float4>

  4. 设置其他简单值

    myComputeShader.SetInt("_MyInt", 42);
  5. 调度执行

    // 参数分别是: threadGroupsX, threadGroupsY, threadGroupsZ
    // 线程组数量 = 总线程数 / 每个线程组的线程数
    myComputeShader.Dispatch(kernelHandle, 125, 1, 1); 

    如果每个线程组有 64 个线程,总线程数是 8000,那么需要 8000 / 64 = 125 个线程组,所以第一个参数是 125


第四部分:第一个完整示例:并行加法

这个例子将展示最基本的数据处理:将两个数组相加,结果存入第三个数组。

目标Result[i] = A[i] + B[i]

1 C# 脚本:ComputeAddExample.cs

using UnityEngine;
public class ComputeAddExample : MonoBehaviour
{
    public ComputeShader addComputeShader;
    public int arraySize = 1024;
    private ComputeBuffer bufferA;
    private ComputeBuffer bufferB;
    private ComputeBuffer resultBuffer;
    void Start()
    {
        // 1. 初始化数据
        float[] dataA = new float[arraySize];
        float[] dataB = new float[arraySize];
        for (int i = 0; i < arraySize; i++)
        {
            dataA[i] = Random.value;
            dataB[i] = Random.value;
        }
        // 2. 创建 ComputeBuffer
        int stride = sizeof(float);
        bufferA = new ComputeBuffer(arraySize, stride);
        bufferB = new ComputeBuffer(arraySize, stride);
        resultBuffer = new ComputeBuffer(arraySize, stride);
        // 3. 将数据写入 Buffer
        bufferA.SetData(dataA);
        bufferB.SetData(dataB);
        // 4. 获取 Kernel Handle
        int kernelHandle = addComputeShader.FindKernel("CSAdd");
        // 5. 绑定 Buffer 到 Shader
        addComputeShader.SetBuffer(kernelHandle, "BufferA", bufferA);
        addComputeShader.SetBuffer(kernelHandle, "BufferB", bufferB);
        addComputeShader.SetBuffer(kernelHandle, "Result", resultBuffer);
        // 6. 调度计算
        // 我们需要 arraySize 个线程,每个线程计算一个元素
        // 假设 numthreads 是 64
        int threadGroupCount = Mathf.CeilToInt((float)arraySize / 64.0f);
        addComputeShader.Dispatch(kernelHandle, threadGroupCount, 1, 1);
        // 7. 读取结果
        float[] resultData = new float[arraySize];
        resultBuffer.GetData(resultData);
        // 8. 验证结果 (打印前5个)
        for (int i = 0; i < 5; i++)
        {
            Debug.Log($"Result[{i}] = {resultData[i]}, Expected = {dataA[i] + dataB[i]}");
        }
        // 9. 释放资源 (非常重要!)
        bufferA.Release();
        bufferB.Release();
        resultBuffer.Release();
    }
}

2 Compute Shader:Add.compute

// 在 Unity 中创建一个新的 Compute Shader 文件,并命名为 Add.compute
// 然后粘贴以下代码
#pragma kernel CSAdd
// 定义三个缓冲区
// RW 表示可读写,StructuredBuffer 表示只读
RWStructuredBuffer<float> Result;
StructuredBuffer<float> BufferA;
StructuredBuffer<float> BufferB;
[numthreads(64, 1, 1)] // 每个线程组包含 64 个线程
void CSAdd (uint3 id : SV_DispatchThreadID)
{
    // SV_DispatchThreadID.x 就是全局唯一的索引 i
    // 确保不越界
    if (id.x < Result.Length)
    {
        Result[id.x] = BufferA[id.x] + BufferB[id.x];
    }
}

3 运行与结果验证

  1. Add.compute 文件拖入 Unity 项目。
  2. 创建一个空 GameObject,命名为 ComputeAddManager
  3. ComputeAddExample.cs 脚本附加到该 GameObject。
  4. Add.compute 文件拖拽到脚本的 addComputeShader 字段上。
  5. 运行场景,查看 Console 窗口输出的日志,验证结果是否正确。

第五部分:进阶示例:图像模糊处理

这个例子将展示如何读写纹理,实现一个简单的图像模糊效果。

1 理解图像纹理在 Compute Shader 中的访问

在 Shader 中,我们需要一个 RWTexture2D 来读写像素数据。

RWTexture2D<float4> ResultTexture; // float4 代表 RGBA 颜色

在 C# 中,我们需要使用 RenderTexture 作为 RWTexture2D 的载体。

2 使用 RWTexture2D

如上所示,在 Shader 中声明,在 C# 中,通过 SetTexture 绑定。

3 编写模糊算法

我们将实现一个简单的 3x3 盒式模糊,每个像素的新值是其周围 8 个邻域像素和自身值的平均值。

Shader: Blur.compute

#pragma kernel CSMain
// 输入纹理(只读)
Texture2D<float4> InputTexture;
// 输出纹理(可读写)
RWTexture2D<float4> ResultTexture;
// 获取纹理尺寸
int _TextureWidth;
int _TextureHeight;
[numthreads(8, 8, 1)]
void CSMain (uint3 id : SV_DispatchThreadID)
{
    // id.xy 是像素的屏幕坐标
    int2 pixelCoords = (int2)id.xy;
    // 检查是否在纹理范围内
    if (pixelCoords.x >= _TextureWidth || pixelCoords.y >= _TextureHeight)
    {
        return;
    }
    float4 colorSum = float4(0, 0, 0, 0);
    int sampleCount = 0;
    // 3x3 核
    for (int x = -1; x <= 1; x++)
    {
        for (int y = -1; y <= 1; y++)
        {
            int2 sampleCoords = pixelCoords + int2(x, y);
            // 确保采样坐标不越界
            if (sampleCoords.x >= 0 && sampleCoords.x < _TextureWidth &&
                sampleCoords.y >= 0 && sampleCoords.y < _TextureHeight)
            {
                // 使用.Load() 方法进行整数坐标采样,非常高效
                colorSum += InputTexture.Load(int3(sampleCoords, 0));
                sampleCount++;
            }
        }
    }
    ResultTexture[pixelCoords] = colorSum / sampleCount;
}

4 C# 脚本中的纹理设置与调度

C# Script: BlurExample.cs

using UnityEngine;
public class BlurExample : MonoBehaviour
{
    public ComputeShader blurComputeShader;
    public Material displayMaterial; // 用于显示结果的材质
    private RenderTexture sourceRenderTexture;
    private RenderTexture resultRenderTexture;
    void Start()
    {
        // 1. 创建 RenderTexture
        int width = 512;
        int height = 512;
        sourceRenderTexture = new RenderTexture(width, height, 0);
        sourceRenderTexture.enableRandomWrite = true; // 允许从 Compute Shader 写入
        sourceRenderTexture.Create();
        // 初始化源纹理(绘制一个渐变或从某个纹理复制)
        // 这里我们简单地将相机输出渲染到源纹理上
        // 假设有一个主相机
        Camera.main.targetTexture = sourceRenderTexture;
        resultRenderTexture = new RenderTexture(width, height, 0);
        resultRenderTexture.enableRandomWrite = true;
        resultRenderTexture.Create();
        // 2. 获取 Kernel
        int kernelHandle = blurComputeShader.FindKernel("CSMain");
        // 3. 设置纹理尺寸
        blurComputeShader.SetInt("_TextureWidth", width);
        blurComputeShader.SetInt("_TextureHeight", height);
        // 4. 绑定纹理
        blurComputeShader.SetTexture(kernelHandle, "InputTexture", sourceRenderTexture);
        blurComputeShader.SetTexture(kernelHandle, "ResultTexture", resultRenderTexture);
        // 5. 调度
        // 线程组数量 = (纹理宽度 / numthreads.x, 纹理高度 / numthreads.y)
        int threadGroupsX = Mathf.CeilToInt((float)width / 8.0f);
        int threadGroupsY = Mathf.CeilToInt((float)height / 8.0f);
        blurComputeShader.Dispatch(kernelHandle, threadGroupsX, threadGroupsY, 1);
        // 6. 显示结果
        displayMaterial.mainTexture = resultRenderTexture;
    }
    void OnRenderImage(RenderTexture src, RenderTexture dest)
    {
        // 将处理后的结果绘制到屏幕上
        Graphics.Blit(resultRenderTexture, dest);
    }
    void OnDestroy()
    {
        // 释放资源
        if (sourceRenderTexture != null) sourceRenderTexture.Release();
        if (resultRenderTexture != null) resultRenderTexture.Release();
    }
}

操作步骤

  1. 创建一个场景,添加一个 Quad 或 Plane,并创建一个新的 Material。
  2. BlurExample.cs 脚本附加到主相机或一个空物体上。
  3. Blur.compute 文件拖到脚本的 blurComputeShader 字段。
  4. 将刚刚创建的 Material 拖到脚本的 displayMaterial 字段。
  5. 将该 Material 应用到场景中的 Quad 上。
  6. 运行场景,你会看到 Quad 上的图像被模糊了。

第六部分:性能优化与最佳实践

1 内存带宽瓶颈

GPU 计算的瓶颈通常不是计算速度,而是从显存读取和写入数据的速度。尽量减少数据在 GPU 和 CPU 之间的来回传输

2 内存合并访问

这是 GPU 编程最重要的优化技巧之一。

  • 坏例子:每个线程访问一个不连续的内存位置。

    // 假设 buffer stride 是 16 (float4)
    // 线程 0 访问 buffer[0], 线程 1 访问 buffer[16], 线程 2 访问 buffer[32] ...
    // 这会导致大量的内存读取请求,效率极低。
    RWStructuredBuffer<float4> MyBuffer;
    MyBuffer[SV_DispatchThreadID.x * 4] = ...; 
  • 好例子:每个线程访问一个连续的内存位置。

    // 线程 0 访问 buffer[0], 线程 1 访问 buffer[1], 线程 2 访问 buffer[2] ...
    // GPU 可以将多个连续的请求合并成一次大的内存传输,效率极高。
    RWStructuredBuffer<float4> MyBuffer;
    MyBuffer[SV_DispatchThreadID.x] = ...; 

    规则:让相邻的线程访问相邻的内存。

3 线程组大小的选择

numthreads 的选择会影响性能,通常选择 64, 128, 256, 512 等值,对于大多数现代 GPU,numthreads(8, 8, 1) (64 threads) 或 numthreads(16, 8, 1) (128 threads) 是很好的起点,具体哪个最好需要通过性能分析来确定。

4 同步操作:GroupMemoryBarrier()

当线程组内的线程需要共享数据时(通过 groupshared 变量),它们需要同步,一个线程写入共享内存后,必须确保其他所有线程都读取到了最新的数据,才能继续执行。

GroupMemoryBarrier() 会暂停一个线程组内的所有线程,直到所有线程都执行到这个屏障点之后,这确保了共享内存的写入对所有后续读取都是可见的。

示例:一个简单的归约求和(虽然 Unity 内置函数更高效,但此例用于展示同步)。

groupshared float partialSum;
[numthreads(64, 1, 1)]
void MyKernel (uint3 id : SV_DispatchThreadID)
{
    // 1. 每个线程写入自己的值
    partialSum = MyBuffer[id.x];
    // 2. 同步!确保所有线程都完成了第一步
    GroupMemoryBarrier();
    // 3. 现在可以进行规约操作(两两相加)
    // ... (省略规约逻辑)
}

5 资源的生命周期管理

务必在不再需要 ComputeBufferRenderTexture 时调用 Release() 方法,否则会导致内存泄漏,长时间运行后可能导致程序崩溃,最好的地方是在 OnDestroy() 方法中释放。


Compute Shader 是一个功能强大的工具,它打开了 GPU 通用计算的大门,掌握它的关键在于:

  1. 理解并行模型:将问题分解为可以由成千上万个独立线程同时处理的小任务。
  2. 精通数据流:熟练使用 ComputeBufferRenderTexture 在 CPU 和 GPU 之间高效地传递数据。
  3. 掌握 HLSL 语法:特别是内置 ID 变量和资源类型声明。
  4. 遵循最佳实践:尤其是内存合并访问,这是获得高性能的核心。

希望这份教程能帮助你顺利入门 Compute Shader 的世界!

分享:
扫描分享到社交APP
上一篇
下一篇