Compute Shader

使用 Compute Shader 可以充分发挥 GPU 的并行计算能力,显著提升复杂计算任务的性能,特别是在需要大规模并行计算的场景中。它提供了自由灵活的计算流程,适用于各种非图形的通用计算任务,同时还能无缝结合渲染任务,为开发者带来更大的优化空间和跨平台支持。

常量定义

CBuffer(常量缓冲区)

CBuffer 是最常用的方式之一,用于在 GPU 程序中定义常量,可以从 Unity C# 脚本中传递数据。常量缓冲区中的数据是从外部提供的(通常通过 CPU 传递给 GPU),并且在计算着色器的所有线程中共享。

1
2
3
4
5
6
7
// ComputeShader 中定义一个常量缓冲区
CBuffer MyConstants
{
float4 someColor; // 颜色常量
int someIntValue; // 整型常量
float someFloatValue; // 浮点数常量
};

在 C# 中传数据过去

1
2
3
4
// 从 C# 脚本设置常量缓冲区中的值
computeShader.SetVector("someColor", new Vector4(1.0f, 0.0f, 0.0f, 1.0f));
computeShader.SetInt("someIntValue", 42);
computeShader.SetFloat("someFloatValue", 5.0f);

#define

#define 用于定义编译时的常量。它通常用于定义一些固定的值,如数学常量、数组大小、预处理条件等。#define 是纯粹的预处理指令,定义的常量在编译期间替换掉相应的值,无法从外部动态修改。

1
2
3
4
5
6
7
8
9
#define PI 3.14159
#define MAX_LIGHTS 10

[numthreads(8, 8, 1)]
void CSMain(uint3 id : SV_DispatchThreadID)
{
//...
}

static const

static const 是另一种定义常量的方法,表示常量在 GPU 程序中是不可修改的,并且在整个程序运行期间保持不变。这种方法适合定义不需要从外部脚本传递的固定常量值。

1
2
3
4
5
6
7
8
static const float gravity = 9.8;
static const int maxParticles = 1000;

[numthreads(8, 8, 1)]
void CSMain(uint3 id : SV_DispatchThreadID)
{
//...
}

const

const 关键字也可以用于定义局部常量,类似于 static const,但 const 主要用于函数或局部作用域的常量。

1
2
3
4
5
6
[numthreads(8, 8, 1)]
void CSMain(uint3 id : SV_DispatchThreadID)
{
const float speed = 10.0f;
float distance = speed * 5.0f;
}

const 常量仅在当前作用域有效,通常用于函数内部的局部常量。

在 struct 中定义常量

有时常量会被嵌入到自定义结构体中,以便在数据结构中更有组织地存储和传递。

1
2
3
4
5
6
struct Particle
{
float3 position;
float3 velocity;
static const float mass = 1.0; // 常量定义在结构体中
};

虽然结构体中可以定义常量,但它们通常是用于在某些算法中表示不变的物理量或者其他不会动态变化的值。

总结

在 Compute Shader 中,常量的定义有多种方式,具体选择取决于常量的用途和是否需要从外部传递:

  • cbuffer:用于从 C# 脚本动态传递常量。
  • #define:用于编译时的宏定义,适合固定的值或条件编译。
  • static const 和 const:用于定义程序中不可变的常量,适合局部或全局的常量。

numthreads

在计算着色器(Compute Shader)中,numthreads 用于定义每个工作组(Work Group)中的线程数。这个参数是一个三维向量,通常表示为 numthreads(x, y, z),其中:

x 是工作组中线程的数量(在 X 轴上的)。
y 是工作组中线程的数量(在 Y 轴上的)。
z 是工作组中线程的数量(在 Z 轴上的)。

numthreads 的分配:

工作组的概念:

工作组是执行计算着色器时的一个基本单元。每个工作组包含多个线程,这些线程可以并行执行工作。
线程数的选择:

numthreads 的值会影响并行计算的性能。你需要根据 GPU 的硬件特性、工作负载的性质以及数据的访问模式来选择合适的线程数。
通常,选择的线程数应考虑到 GPU 的架构,例如大多数现代 GPU 都有优化的工作组大小(通常是 32 或 64 的倍数)。

调度和执行:

在执行计算时,GPU 会将多个工作组排队并调度执行。每个工作组的线程会共享一些资源(如本地内存),因此在设计时需要注意资源的使用。

数据划分

计算任务需要根据 numthreads 来划分数据。例如,如果你处理的是一个二维纹理,可以使用 numthreads(8, 8, 1),然后根据线程的 ID 来计算每个线程处理的纹理区域。
示例:
如果你有一个计算任务需要处理一个 256x256 的数据,可以这样设置:

1
2
3
4
5
[numthreads(16, 16, 1)]
void CSMain(uint3 id : SV_DispatchThreadID)
{
// 处理数据逻辑
}

在这个例子中,每个工作组包含 256 个线程(16x16),可以高效地处理一个 256x256 的数据块。
如果你处理的是 3维纹理,比如 Cube Texture,就可以给按照(8,8,8)来分配,每个工作组有 512 个线程。

反面案例

一个 256 * 256 的纹理用 (16,16,1)的分组方式刚好,如果用 (8,8,1)的分组方式就会得到下面的结果。
反面教材

接近黑色的底图是我用 (16,16,1)的分组方式处理的,(8,8,1)就会得到左下角的错误结果,即纹理的其他地方是没有数据的。

不规则纹理的数据划分

一般来说除了特定的等身图,大部分纹理都是不规则的,我的建议是上调至按照 2 的倍数来计算的上一个幂数,即使你创建的纹理仍然不是规则的。

使用案例

一个简单的教程,我传入 Compute Shader 一张纹理,然后在里面做一些处理,同时我在外面只需要将这张纹理设置为某个 Mat 下面的纹理,就可以拿到 Compute Shader 的处理结果了,不需要再通过 GPU 传回 CPU了。

下面的案例只是在 Compute Shader 中计算一个颜色,然后直接赋给 Result,Result 又是在 C# 中的一个 Render Texture,再将这个 Render Texture 直接赋给一个 Quad 或者 Plane 就可以看到效果了。

EzExample

1
2
3
4
5
6
7
float4 baseColor = float4(id.x & id.y, (id.x & 15)/15.0, (id.y & 15)/15.0, 1.0);
float4 color = Result[id.xy];
color.r += 0.01f;
color.g += 0.005f;
color.b += 0.008f;
color = frac(color);
Result[id.xy] = float4(color) + baseColor * 0.5f;

StructuredBuffer

使用 Compute Shader 时难免需要 CPU 和 GPU 之间通信,在 ShaderLab 中可以直接通过 Material.SetInt() 等方式传递数据,而在 Compute Shader 中就需要 StructuredBuffer 来完成这件事。

StructuredBuffer 使用步骤

使用 StructuredBuffer 需要自定义结构,并且需要在两边都定义,然后通过 ComputeBuffer.SetData() 传过去。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
//c# 端
struct Particle
{
public Vector3 prevposition;
public Vector3 position;
};
private Particle[] particleArray;

void Start()
{
particleArray = new Particle[12];
cBuffer = new ComputeBuffer(particleArray.Length, 12 + 12 );
cBuffer.SetData(particleArray);
}

// Compute 端
struct Particle
{
float3 prevposition;
float3 position;
};

StructuredBuffer<Particle> particleBuffer;

这里 C# 端注意 ComputeBuffer 实例化的时候有一个参数 stride,这个需要和传入的数据想对应的,这个 stride 就是传入数据的单个长度,不能出错,如果错了就会有内存上的问题。

通信同步问题

在使用 Compute Shader 时,GPU 和 CPU 的通信是一个重要的环节,因为它们是并行工作的,通常在不同的时间线上处理任务。为了确保两者之间的数据同步,避免数据竞争和不一致的问题,需要特别注意一些细节和同步机制。以下是一些注意事项和同步的策略:

GPU 和 CPU 是异步工作的

GPU 和 CPU 是独立的处理器,它们并行执行指令。通常,CPU 向 GPU 提交一个任务(如调用 Dispatch 来执行 Compute Shader),但 CPU 并不会等待 GPU 完成任务,而是继续执行其后续的逻辑。这种异步工作模式可能导致数据不同步的问题,例如 CPU 在 GPU 计算完成前尝试访问尚未准备好的数据。

使用 ComputeBuffer 或 RenderTexture 进行数据通信

GPU 和 CPU 之间的通信通常通过 ComputeBuffer 或 RenderTexture 等共享资源进行。为了确保数据正确同步,你需要在读写这些资源时特别注意同步机制。

使用 ComputeBuffer 和 GraphicsBuffer

在使用 ComputeBuffer 时,CPU 可以将数据写入缓冲区,然后通过 Compute Shader 在 GPU 上进行计算,最后再通过 CPU 从缓冲区读取数据。然而,如果 GPU 尚未完成对缓冲区的计算,CPU 过早读取数据可能会导致错误结果。

解决方法:
调用 buffer.GetData() 来读取数据: 在 CPU 从 GPU 读取数据时,使用 buffer.GetData() 这个函数。该函数会隐式地确保 GPU 计算已完成,等待 GPU 计算结束后才会返回数据。通常这是最简单的同步方法。

1
2
3
4
5
6
7
8
9
10
11
ComputeBuffer computeBuffer = new ComputeBuffer(dataLength, sizeof(float));

// CPU 向 GPU 发送数据
computeBuffer.SetData(inputData);

// GPU 执行 Compute Shader 计算
computeShader.SetBuffer(kernelID, "bufferName", computeBuffer);
computeShader.Dispatch(kernelID, threadGroupsX, threadGroupsY, threadGroupsZ);

// CPU 等待 GPU 计算完成并读取结果
computeBuffer.GetData(outputData);

GetData() 会确保 GPU 完成对 ComputeBuffer 的所有操作,并同步结果回 CPU。如果 GPU 任务尚未完成,它会阻塞 CPU,直到 GPU 处理完毕。

使用 AsyncGPUReadback(异步读取 GPU 数据)

AsyncGPUReadback 是 Unity 提供的一个方法,允许 GPU 的数据异步传递给 CPU,避免阻塞主线程。在不要求立即获得结果的场景中,AsyncGPUReadback 可以减少 CPU 等待 GPU 完成计算的时间。

1
2
3
4
5
6
7
8
9
// 异步读取 GPU 数据到 CPU
AsyncGPUReadback.Request(computeBuffer, (request) => {
if (request.hasError) {
Debug.LogError("GPU readback error");
return;
}
// 读取成功,获取数据
var data = request.GetData<float>();
});

优点:AsyncGPUReadback 允许 CPU 不会立即等待 GPU 完成,特别适合处理大批量的数据读取,提升效率。
注意事项:它的结果是异步返回的,需要通过回调函数来处理 GPU 的数据,适合非即时数据需求的场景。

确保 Dispatch 执行完毕

当你调用 ComputeShader.Dispatch() 时,CPU 只是向 GPU 提交了一个工作,而 GPU 可能尚未完成计算。因此,如果需要同步,你需要确保 GPU 已经完成计算任务。

解决方法:
使用 CommandBuffer 的 WaitAllAsyncReadbackRequests: 如果你在复杂的渲染管线或异步数据读取中使用 CommandBuffer,可以调用 CommandBuffer.WaitAllAsyncReadbackRequests 来确保所有异步请求完成。

使用 GraphicsFence: 在 Unity 中可以通过 GraphicsFence 来确保 GPU 操作完成。例如,在渲染过程中可以使用 CommandBuffer.IssuePluginEvent 来等待 GPU 任务完成,但在一般的 Compute Shader 场景中,较少使用这种低层次的同步方法。

1
2
3
CommandBuffer cmd = new CommandBuffer();
var fence = cmd.CreateGraphicsFence(GraphicsFenceType.AsyncQueueSynchronisation, SynchronisationStageFlags.ComputeProcessing);
cmd.WaitOnAsyncGraphicsFence(fence);

避免频繁 CPU-GPU 同步

同步是必要的,但频繁的同步可能会导致性能问题,因为 CPU 会被迫等待 GPU 任务的完成。以下是一些优化同步性能的建议:

减少 GPU 数据回传:尽量减少 GPU 数据回传到 CPU 的次数,特别是在帧率要求高的场景中。
尽量使用 AsyncGPUReadback:在非即时需求场景下,优先使用 AsyncGPUReadback 来避免阻塞主线程。
批量处理数据:尽量在 GPU 上完成更多的批量处理任务,减少 CPU 和 GPU 之间频繁的数据交换。

其他同步机制

在一些低层次的 API 中,可能还会涉及 Fence 或 Events 等 GPU 同步机制,但在 Unity 中,高层次的 API(如 ComputeBuffer.GetData() 和 AsyncGPUReadback)已经封装了常见的同步操作,通常不需要显式地处理这些底层细节。

通信同步总结

在 Unity 使用 Compute Shader 时,CPU 和 GPU 通信需要注意的同步问题主要包括以下几点:

  • 异步执行机制:CPU 和 GPU 是并行工作的,CPU 提交任务后,GPU 计算未完成时 CPU 可能继续运行。
  • 同步方法:
  • 使用 ComputeBuffer.GetData() 等方法同步读取 GPU 结果。
  • 使用 AsyncGPUReadback 进行异步数据回传,减少同步的阻塞。
  • 确保 Dispatch 完成:在需要同步的地方,确保 GPU 的任务执行完毕,可以使用 CommandBuffer 和 GraphicsFence。
  • 性能优化:减少频繁同步操作,尽量让 GPU 处理更多批量任务,并使用异步回传的方式来减少性能瓶颈。
  • 通过合理地管理 GPU 和 CPU 之间的数据同步,你可以更好地利用 GPU 的计算能力,同时避免不必要的性能开销。

参考链接