Compute Shader 是 Unity 中释放 GPU 通用计算能力的关键通道。本文将系统梳理其原理、API、URP 集成方式与实战案例。
一、为什么需要 Compute Shader
在传统的渲染管线中,GPU 被严格限定在"顶点 → 光栅化 → 片元"这条流水线上。如果你想让 GPU 帮你做点别的事情——比如计算大量粒子的物理运动、生成程序化地形、或者做图像后处理——就会发现自己被这条管线束缚住了。Compute Shader 正是为了打破这种束缚而生。

传统渲染管线是一条严格的单向流水线:CPU 提交 Draw Call,顶点着色器处理几何,硬件光栅化,片元着色器逐像素上色,最终写入 Framebuffer。每一步的输出格式几乎是定死的。Compute Shader 则完全跳过了这套流程——你只需要告诉 GPU "启动 N 个线程,每个线程执行这段代码",然后自由读写任意 Buffer 或 Texture。
二、CPU 与 GPU 计算模型的本质差异
理解 Compute Shader 的前提,是先理解 GPU 和 CPU 在计算哲学上的根本差异。


当你有一个需要对百万个元素执行相同操作的场景时,Compute Shader 就是正确答案:它能把原本需要 CPU 串行几秒的工作压缩到 GPU 十几毫秒内完成。
三、Compute Shader 的文件结构与语法
一个 Compute Shader 文件(.compute)的核心要素:Kernel 函数、线程组声明、资源绑定。下面是一个完整的可运行示例:
#pragma kernel CSMain
// ── 可读写纹理 ──
RWTexture2D<float4> Result;
// ── 可读写结构化缓冲区 ──
RWStructuredBuffer<float> OutputBuffer;
// ── 只读 Uniform 变量 ──
float DeltaTime;
uint TotalCount;
// ── Kernel 函数 ──
[numthreads(8, 8, 1)]
void CSMain (uint3 id : SV_DispatchThreadID)
{
// 每个线程处理一个像素 / 一个数组元素
uint index = id.y * TotalCount + id.x;
float val = OutputBuffer[index];
Result[id.xy] = float4(val, val, val, 1.0);
}
让我们逐个解析关键语法:
-
1
#pragma kernel CSMain声明 Kernel 入口点。一个
.compute文件可以包含多个 Kernel,每个都需要一行#pragma kernel。 -
2
[numthreads(X, Y, Z)]定义每个线程组的线程数。这里
(8,8,1)表示每组 64 个线程。总线程数 = 线程组数 × 每组线程数。 -
3
SV_DispatchThreadID全局线程 ID,范围
[0, DispatchDim * numthreads - 1]。用它来索引要处理的数据。 -
4
RWTexture2D / RWStructuredBuffer
RW前缀表示可读写资源。Compute Shader 通过这类句柄直接读写 GPU 内存,无需经过渲染管线。
关键概念:线程组 (Thread Group)
GPU 以线程组为单位调度。同一组内的线程可以访问 Group Shared Memory(组内共享内存,速度接近 L1 缓存),并可以通过 GroupMemoryBarrierWithGroupSync() 同步。这是实现高效 Reduce(规约)操作的基础。
四、Unity C# 端:创建、设置与 Dispatch
Compute Shader 本身只是 GPU 上的函数定义;真正的调度和参数传递发生在 C# 端。

完整的 C# 端代码示例:
using UnityEngine;
public class ComputeDispatcher : MonoBehaviour
{
public ComputeShader computeShader;
public RenderTexture resultTexture;
ComputeBuffer dataBuffer;
int kernelIndex;
const int DATA_COUNT = 1024;
void Start()
{
// 1. 获取 Kernel 索引
kernelIndex = computeShader.FindKernel("CSMain");
// 2. 创建 ComputeBuffer(float 数组,1024 个元素)
dataBuffer = new ComputeBuffer(DATA_COUNT, sizeof(float));
// 3. 初始化数据
float[] initialData = new float[DATA_COUNT];
for (int i = 0; i < DATA_COUNT; i++)
initialData[i] = Random.value;
dataBuffer.SetData(initialData);
// 4. 创建 RenderTexture
resultTexture = new RenderTexture(32, 32, 0);
resultTexture.enableRandomWrite = true;
resultTexture.Create();
}
void Update()
{
// 绑定资源到 Compute Shader
computeShader.SetBuffer(kernelIndex, "OutputBuffer", dataBuffer);
computeShader.SetTexture(kernelIndex, "Result", resultTexture);
computeShader.SetFloat("DeltaTime", Time.deltaTime);
computeShader.SetInt("TotalCount", DATA_COUNT);
// Dispatch: 启动 1024/(8×8)=16 个线程组(2D: 4×4)
computeShader.Dispatch(kernelIndex, 4, 4, 1);
}
void OnDestroy()
{
if (dataBuffer != null)
dataBuffer.Release();
dataBuffer = null;
}
}
ComputeBuffer vs GraphicsBuffer
Unity 2023+ 推荐使用 GraphicsBuffer 替代 ComputeBuffer。两者功能相似,但 GraphicsBuffer 提供了更细粒度的 Target 控制(Raw / Structured / IndirectArguments 等),并能更好地与 SRP Batcher 协同。
五、URP 中 Compute Shader 的特有集成方式
URP 对 Compute Shader 的支持比 Built-in RP 更加原生,但也引入了一些需要特别注意的机制。
5.1 通过 RenderPipelineManager 注入
URP 提供了多个渲染回调点,可以在管线中插入 Compute Shader 执行:
// 在渲染前执行 Compute Shader
RenderPipelineManager.beginCameraRendering += OnBeginCameraRendering;
void OnBeginCameraRendering(ScriptableRenderContext ctx, Camera cam)
{
shader.SetBuffer(kernel, "_CameraData", cameraBuffer);
shader.Dispatch(kernel, groupX, groupY, 1);
}
// 或在渲染后读取结果
RenderPipelineManager.endCameraRendering += OnEndCameraRendering;
5.2 使用 RTHandle 系统
URP 14+ (Unity 2023+) 全面采用 RTHandle 替代直接创建 RenderTexture。如果 Compute Shader 的输入/输出需要与 URP 内部纹理交互,应当使用 RTHandle:
using UnityEngine.Rendering;
using UnityEngine.Rendering.Universal;
// 创建 RTHandle
RTHandle customRT = RTHandles.Alloc(
"_CustomRT",
scaleFactor: Vector2.one,
colorFormat: GraphicsFormat.R32G32B32A32_SFloat,
enableRandomWrite: true // 允许 Compute Shader 写入
);
// 在 Compute Shader 中使用
shader.SetTexture(kernel, "_ResultTexture", customRT);
shader.Dispatch(kernel, x, y, 1);
5.3 自定义 Renderer Feature
更优雅的做法是将 Compute Shader 封装成一个 ScriptableRendererFeature,作为 URP 的渲染 Pass 插入。下面是完整模板:
using UnityEngine;
using UnityEngine.Rendering;
using UnityEngine.Rendering.Universal;
public class ComputeRendererFeature : ScriptableRendererFeature
{
public ComputeShader computeShader;
public RenderPassEvent renderPassEvent = RenderPassEvent.AfterRenderingTransparents;
ComputePass computePass;
public override void Create()
{
computePass = new ComputePass(computeShader, renderPassEvent);
}
public override void AddRenderPasses(ScriptableRenderer renderer,
ref RenderingData renderingData)
{
if (computeShader != null)
{
computePass.Setup(renderer.cameraColorTargetHandle);
renderer.EnqueuePass(computePass);
}
}
// ── Inner Pass ──
class ComputePass : ScriptableRenderPass
{
ComputeShader shader;
int kernelIndex;
RTHandle source;
public ComputePass(ComputeShader cs, RenderPassEvent evt)
{
shader = cs;
kernelIndex = shader.FindKernel("CSMain");
renderPassEvent = evt;
}
public void Setup(RTHandle src) { source = src; }
public override void Execute(ScriptableRenderContext ctx,
ref RenderingData renderingData)
{
CommandBuffer cmd = CommandBufferPool.Get("ComputePass");
shader.SetTexture(kernelIndex, "_SourceTexture", source);
int gx = Mathf.CeilToInt(source.rt.width / 8.0f);
int gy = Mathf.CeilToInt(source.rt.height / 8.0f);
shader.Dispatch(kernelIndex, gx, gy, 1);
ctx.ExecuteCommandBuffer(cmd);
CommandBufferPool.Release(cmd);
}
}
}

六、实战案例:GPU 粒子系统
这是 Compute Shader 最经典的用例之一。CPU 粒子系统(如 Unity 内置 ParticleSystem)每帧需要遍历所有粒子更新位置、速度、生命周期,当粒子数上万时 CPU 瓶颈显著。GPU 粒子系统将全部运算搬到 Compute Shader,可以让 10 万+ 粒子流畅运行。

GPU 粒子系统的核心思想是:
-
1
GPU 端自循环粒子位置、速度、生命周期全部存储在
ComputeBuffer中,每帧由 Compute Shader 原地更新,从头到尾不经过 CPU。 -
2
直接渲染更新后的 Buffer 直接用作顶点着色器的输入(通过
Graphics.DrawProcedural或DrawMeshInstancedIndirect),无需回读到 CPU。 -
3
无 Draw Call 爆炸所有粒子一次 Draw 完成,使用 GPU Instancing 或 Procedural Drawing。
七、实战案例:Compute Shader 后处理
后处理是 Compute Shader 另一大战场。相比传统的 Blit + Fragment Shader 方案,Compute Shader 后处理有以下几个关键优势:
无光栅化开销
Fragment Shader 后处理需要绘制全屏三角形,触发光栅化。Compute Shader 直接 Dispatch,跳过了整个光栅化阶段。
灵活的读写模式
Compute Shader 可以同时读写同一纹理的不同区域,也可以在一个 Kernel 中完成 Reduce、平均、最大/最小值等多 Pass 操作。
Group Shared Memory
同一线程组内的线程可以共享一块高速内存,这在需要做局部卷积(如 Blur)时能显著减少全局显存访问。
更好的 Mobile 表现
在移动端 GPU(尤其是 Mali、Adreno)上,Compute Shader 调度方式更贴近硬件,许多 TBR 架构对 Compute 的支持优于传统全屏Pass。
以高斯模糊为例,使用 Group Shared Memory 优化的 Kernel:
#pragma kernel HorizontalBlur
Texture2D<float4> Input;
RWTexture2D<float4> Result;
static const int KERNEL_RADIUS = 4;
static const int GROUP_SIZE = 256;
// 高斯权重(半径 4,sigma=2.0)
static const float Weights[9] = {
0.016, 0.054, 0.122, 0.195, 0.226,
0.195, 0.122, 0.054, 0.016
};
// 组内共享内存
groupshared float4 SharedCache[GROUP_SIZE + 8];
[numthreads(GROUP_SIZE, 1, 1)]
void HorizontalBlur(uint3 id : SV_DispatchThreadID,
uint gi : SV_GroupIndex,
uint3 gid : SV_GroupID)
{
uint width, height;
Input.GetDimensions(width, height);
if (id.x >= width || id.y >= height) return;
// 1. 每个线程从全局纹理读入共享内存
SharedCache[gi] = Input[id.xy];
// 2. 组内同步(等待所有线程读入完毕)
GroupMemoryBarrierWithGroupSync();
// 3. 从共享内存做水平卷积(极快)
float4 color = 0;
for (int i = -KERNEL_RADIUS; i <= KERNEL_RADIUS; i++)
{
int idx = gi + i;
color += SharedCache[idx] * Weights[i + KERNEL_RADIUS];
}
Result[id.xy] = color;
}
关键优化:Group Shared Memory
在传统 Fragment Shader 模糊中,每个像素都要从全局纹理采样 9 次,每次采样都经过显存总线。而使用 Group Shared Memory 后,同一线程组内的 256 个像素只需读一次全局纹理,之后全部从片上高速共享内存读取,带宽节省可达 90% 以上。
八、性能优化清单
Compute Shader 虽然强大,但随意的用法同样会导致性能问题。以下是一份实用的优化清单:
| 优化项 | 说明 | 影响 |
|---|---|---|
| 选择合适的 numthreads | 组大小应为 Warp/Wavefront 的倍数。NVIDIA 上 32 的倍数,AMD 上 64 的倍数。常用 64、128、256。 | ★★★★★ |
| 使用 Group Shared Memory | 组内共享内存速度接近 L1 缓存,用于减少重复的全局内存访问。 | ★★★★★ |
| 合并内存访问 | 相邻线程访问相邻内存地址(Coalesced Access),最大化显存带宽利用率。 | ★★★★☆ |
| 避免分支发散 | 同一 Warp/Wave 内线程应走相同分支。用算术运算替代条件分支。 | ★★★★☆ |
| 减少 CPU-GPU 同步 | ComputeBuffer.GetData() 会强制 GPU 完成所有在途命令。仅在必要时回读。 | ★★★★☆ |
| 使用 AsyncGPUReadback | 如果必须回读,用 AsyncGPUReadback.Request() 异步读取,避免阻塞主线程。 | ★★★☆☆ |
| Dispatch 粒度 | 每组线程太少 → GPU 核心闲置;每组太多 → 寄存器溢出。通常 64-256 线程/组是最佳区间。 | ★★★☆☆ |
| 避免纹理随机写入 | 多线程同时写入同一纹理坐标会产生竞争。使用 InterlockedAdd 等原子操作。 | ★★★☆☆ |

九、常见陷阱与解决方案
9.1 "采样点漂移"——纹理坐标
Compute Shader 中的 id.xy 是整数像素坐标,而 Fragment Shader 中 uv 是 [0,1] 归一化坐标。如果从 Fragment Shader 迁移到 Compute Shader,注意坐标系的转换:
// Fragment Shader 风格
float2 uv = id.xy / float2(width, height); // [0,1]
float2 centered = (id.xy + 0.5) / float2(width, height); // 像素中心
// Compute Shader 可以直接使用 SampleLevel
float4 color = Input.SampleLevel(sampler_linear_clamp, uv, 0);
9.2 竞态条件 (Race Condition)
当多个线程同时写入同一内存位置时,结果不确定。解决方案:
-
A
原子操作:
InterlockedAdd、InterlockedMin、InterlockedMax保证读写原子性,但有性能代价。 -
B
分散写入:设计算法让每个线程写入不同的内存位置(如粒子系统中每个线程更新自己的粒子)。
-
C
两阶段 Reduce:先局部规约到共享内存,再原子写回全局缓冲区。
9.3 平台差异
不同平台对 Compute Shader 的支持和限制差异显著。以下是关键对照:
| 平台 | Compute Shader 支持 | 最大 numthreads | 注意 |
|---|---|---|---|
| Windows / DX11+ | 完整支持 | 1024 / 组 | 性能最佳平台 |
| Windows / Vulkan | 完整支持 | 1024 / 组 | 比 DX11 略优的调度 |
| macOS / Metal | 完整支持 | 1024 / 组 | 需注意 TBDR 架构特性 |
| Android / Vulkan | 部分支持 | 256 / 组(常见) | Shared Memory 通常更小 |
| iOS / Metal | 完整支持 | 1024 / 组 | 推荐使用 half 精度 |
| WebGL | 不支持 | — | 无 Compute Shader 路径 |
十、总结
Compute Shader 是现代 Unity 图形开发中不可或缺的工具。它不是某种"优化技巧",而是一种架构选择——当你需要处理大规模数据并行计算时,把工作搬到 GPU 上往往是最优解。

Compute Shader 的学习曲线确实比普通 Shader 稍陡——你需要理解线程模型、内存层级、同步机制。但一旦掌握,它就像在你的工具包里装上了一把瑞士军刀,能解决大量传统渲染管线无法优雅处理的问题。希望本文能帮助你跨过那个门槛,开始在自己的 URP 项目中驾驭 GPU 并行计算的力量。
285



被折叠的 条评论
为什么被折叠?



