Compute Shader 完整教程:从入门到精通
目录
-
第一部分:核心概念
(图片来源网络,侵删)- 1 什么是 Compute Shader?
- 2 为什么需要 Compute Shader?(优势)
- 3 Compute Shader 与传统渲染流水线的区别
- 4 关键概念:线程、线程组、线程组ID
-
第二部分:HLSL 基础语法
- 1 结构体与数据类型
- 2 内置变量(
numthreads,SV_DispatchThreadID,SV_GroupThreadID,SV_GroupID) - 3 核心函数:
numthreads和kernel
-
第三部分:Unity 中的 Compute Shader 资源
- 1 创建 Compute Shader 文件
- 2
ComputeShader类与 C# 脚本的交互 - 3 关键资源类型:
ComputeBuffer - 4 资源绑定与
ComputeShader.Set...方法
-
第四部分:第一个完整示例:并行加法
- 1 C# 脚本:准备数据与调度计算
- 2 Compute Shader:编写核心计算逻辑
- 3 运行与结果验证
-
第五部分:进阶示例:图像模糊处理
(图片来源网络,侵删)- 1 理解图像纹理在 Compute Shader 中的访问
- 2 使用
RWTexture2D - 3 编写模糊算法
- 4 C# 脚本中的纹理设置与调度
-
第六部分:性能优化与最佳实践
- 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 |
|---|---|---|
| 主要用途 | 渲染图形 | 通用计算 |
| 输入数据 | 顶点数据、纹理、插值后的变量 | 任意 Buffer 或 Texture |
| 输出数据 | 渲染到屏幕或 Render Target | 写入 Buffer 或 Texture |
| 执行流程 | 固定流水线(顶点->光栅化->片段) | 灵活的“内核”调度 |
| 访问控制 | 只能读/写特定资源 | 可以读写任意绑定的资源 |
4 关键概念:线程、线程组、线程组ID
这是理解 Compute Shader 工作方式的核心。

- 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 核心函数:numthreads 和 kernel
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 的“指挥官”,它负责:
- 加载
.compute文件。 - 创建和管理数据资源(主要是
ComputeBuffer)。 - 将数据资源绑定到 Shader 的特定变量槽。
- 调度
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 方法完成。
-
设置 Kernel:
int kernelHandle = myComputeShader.FindKernel("MyKernel"); -
绑定 Buffer:
// 将 C# 创建的 myBuffer 绑定到 Shader 中名为 "OutputBuffer" 的变量 myComputeShader.SetBuffer(kernelHandle, "OutputBuffer", myBuffer);
Shader 中的变量需要用
[numthreads(...)]修饰,并且通常是RWStructuredBuffer或StructuredBuffer。 -
绑定 Texture:
// 绑定一个可读写的 2D 纹理 myComputeShader.SetTexture(kernelHandle, "ResultTexture", myRenderTexture);
Shader 中的变量需要是
RWTexture2D<float4>。 -
设置其他简单值:
myComputeShader.SetInt("_MyInt", 42); -
调度执行:
// 参数分别是: 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 运行与结果验证
- 将
Add.compute文件拖入 Unity 项目。 - 创建一个空 GameObject,命名为
ComputeAddManager。 - 将
ComputeAddExample.cs脚本附加到该 GameObject。 - 将
Add.compute文件拖拽到脚本的addComputeShader字段上。 - 运行场景,查看 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();
}
}
操作步骤:
- 创建一个场景,添加一个 Quad 或 Plane,并创建一个新的 Material。
- 将
BlurExample.cs脚本附加到主相机或一个空物体上。 - 将
Blur.compute文件拖到脚本的blurComputeShader字段。 - 将刚刚创建的 Material 拖到脚本的
displayMaterial字段。 - 将该 Material 应用到场景中的 Quad 上。
- 运行场景,你会看到 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 资源的生命周期管理
务必在不再需要 ComputeBuffer 或 RenderTexture 时调用 Release() 方法,否则会导致内存泄漏,长时间运行后可能导致程序崩溃,最好的地方是在 OnDestroy() 方法中释放。
Compute Shader 是一个功能强大的工具,它打开了 GPU 通用计算的大门,掌握它的关键在于:
- 理解并行模型:将问题分解为可以由成千上万个独立线程同时处理的小任务。
- 精通数据流:熟练使用
ComputeBuffer和RenderTexture在 CPU 和 GPU 之间高效地传递数据。 - 掌握 HLSL 语法:特别是内置 ID 变量和资源类型声明。
- 遵循最佳实践:尤其是内存合并访问,这是获得高性能的核心。
希望这份教程能帮助你顺利入门 Compute Shader 的世界!
