Compute Shader
目录+
Compute Shader 让我们能直接把通用计算任务交给 GPU 并行处理,而不局限于传统的顶点/片元着色。要写好它,先得理解 GPU 是如何把成千上万个线程分配到硬件上执行的。
GPU 的并行执行层级
GPU 的并行能力来自一套分级的线程调度体系。从最底层的执行单元到开发者组织的线程集合,NVIDIA 和 AMD 有各自的术语,但结构基本对应。
执行单元:SP / CUDA Core
SP(Streaming Processor)是 GPU 最基本的执行单元,在 NVIDIA 中也叫 CUDA Core,相当于 CPU 里的算术/逻辑单元。所有算术、逻辑、数据搬运指令最终都在 SP 上执行,每个 SP 通常一个时钟周期处理一个线程的一条指令。
GPU 之所以能高并发,正是因为它拥有成百上千个 SP 同时工作——市场上宣传的"几千核心"指的就是 SP(CUDA Core)的数量。AMD 架构中对应的执行单元通常称为 SIMD 单元,实现细节略有不同。
调度单元:SM / Compute Unit
SM(Streaming Multiprocessor)是比 SP 更高一级的并行单元,常被称为"大核"。每个 SM 内部集成了多个 SP(例如 Ampere 架构一个 SM 有 128 个 SP),此外还有调度单元、共享内存(shared memory)、寄存器文件、常量缓存等资源。一个 SM 可以同时调度和执行多个 warp,实现细粒度的线程级并行。
线程块(thread block)会被整体分配到某一个 SM 上,由该 SM 的内部资源协同调度执行。
AMD 的 Compute Unit(CU)功能上等同于 SM。每个 CU 内含多个 SIMD 执行单元(如 GCN 架构为 4 个 SIMD-16),同样有自己的调度器、寄存器和局部数据共享内存。工作组(work group)被分配到 CU 上,交由 SIMD 单元执行。
调度批次:Warp / Wavefront
线程并不是一个个单独调度的,而是成批执行。Warp 是 NVIDIA 架构中线程调度与并行执行的最小单位,一个 warp 包含 32 个线程,它们被同时发往 SP 阵列,同步执行同一条指令,这就是 SIMT 架构。一个 SM 可以同时调度多个 warp,从而隐藏内存访问等延迟、充分利用硬件。
需要注意 warp divergence:如果一个 warp 内的线程走了不同的分支,硬件只能把各分支串行执行,性能随之下降。
AMD 对应的调度单位是 Wavefront,每个包含 64 个线程,采用 SIMD-16 或 SIMD-32 组织。同样地,wavefront 内所有线程需执行同一条指令,遇到分支分歧时也要串行处理。
从线程到硬件的映射
一次 GPU 计算从提交线程到实际执行,大致经历这几步:
- 开发者提交大量并行线程(如逐像素、逐矩阵元素处理)。
- 线程被分组为线程块(NVIDIA 叫 thread block,AMD 叫 work group),每个线程块分配到一个 SM 或 CU。
- SM/CU 内部再把线程块拆成多个 warp(32 线程)或 wavefront(64 线程)。
- 这些 warp/wavefront 被调度到 SP/SIMD 阵列上,以 SIMD 方式同步并行执行。
- 调度器不断在就绪的 warp/wavefront 之间切换,用计算掩盖访存延迟。
NVIDIA 与 AMD 的术语对照如下:
| 层级 | NVIDIA | AMD |
|---|---|---|
| 基本执行单元 | SP / CUDA Core | SIMD 单元 |
| 调度单元 | SM | CU |
| 调度批次 | Warp(32 线程) | Wavefront(64 线程) |
| 线程集合 | Thread Block | Work Group |

Compute Shader 的基本结构
下面是一个最简单的 Compute Shader,它给一张可读写纹理逐像素写入颜色:
#pragma kernel CSMain
RWTexture2D<float4> Result;
[numthreads(8,8,1)]
void CSMain (uint3 id : SV_DispatchThreadID)
{
Result[id.xy] = float4(id.x & id.y, (id.x & 15)/15.0, (id.y & 15)/15.0, 0.0);
}
#pragma kernel CSMain 把名为 CSMain 的函数声明为内核(核函数),它是最终在 GPU 上执行的入口。一个 Compute Shader 至少要有一个 kernel 才能被唤起。
RWTexture2D<float4> Result 声明了一个可读写的二维纹理。纹理由像素组成,每个像素有自己的下标,因此可以按下标访问,例如 Result[uint2(0,0)]。尖括号里的 float4 是每个像素的值类型,通常对应一个 RGBA 颜色。常见做法是在 Compute Shader 里处理好纹理,再交给 Fragment Shader 采样。
[numthreads(8,8,1)] 定义一个线程组(Thread Group)中的线程数量,这里是 8×8×1 = 64 个。理解它需要区分两个概念:
- 线程组(Thread Group):GPU 批量执行线程,一个线程组会被整体放到一个 SM 上运行。假如 GPU 有 16 个 SM,至少需要 16 个线程组才能让所有 SM 有活干;实践中每个 SM 通常分配两个以上线程组,这样当一个组因等待纹理结果而阻塞时,SM 能切换到另一个组继续计算,从而隐藏延迟。
- 线程(Thread):
CSMain会被每个线程独立执行一次。一个 64 线程的组,就有 64 个线程各自跑一遍CSMain。同一组内的线程可以通过共享内存(groupshared修饰的变量)交换数据并做同步;不同组之间既不能访问彼此的共享内存,也不能同步,跨组的数据交流只能走显存。
void CSMain (uint3 id : SV_DispatchThreadID) 是内核入口。参数 id 为 uint3 类型(x/y/z 三个无符号整数),通过语义 SV_DispatchThreadID 获得当前线程在整个 Dispatch 空间中的全局唯一坐标——注意它是全局 ID,不是线程组内的局部 ID。
线程的三维编号

上图分两部分:上半部分是线程组的排布,下半部分放大了单个线程组内部的线程排布。
图中 Dispatch(5, 3, 2) 表示一次分发了 5×3×2 = 30 个线程组,这些组的执行顺序不保证(可以并行也可以乱序),每个小方块是一个线程组,由 SV_GroupID 标识。下半部分放大了 SV_GroupID = (2,1,0) 这个组:它被定义为 10×8×3 的三维网格,即每个组有 10×8×3 = 240 个线程,每个小格子是一个线程,由 SV_GroupThreadID 定位。
几个常用的线程编号语义:
SV_GroupThreadID:线程在所属线程组内部的三维索引,例如 (7,5,0)。SV_GroupID:线程组自身的三维坐标,例如 (2,1,0)。SV_GroupIndex:把组内线程展平后的线性编号,常用于索引组内共享内存数组。SV_DispatchThreadID:当前线程在整个 Dispatch 空间(所有线程组的所有线程构成的完整三维空间)中的全局唯一坐标。
三者的换算关系为:
SV_DispatchThreadID = SV_GroupID × 线程组网格大小 + SV_GroupThreadID
即「线程组坐标 × 单组线程数 + 组内线程坐标」。
实例:在 URP 中输出渐变色
下面用一个 Compute Shader 逐像素生成渐变色,并通过 RenderFeature 接入 URP。效果如下:

RenderFeature 负责创建临时 RT 并绑定到 Compute Shader,按屏幕尺寸计算并派发线程组,最后把处理后的 RT 输出到相机颜色目标:
using Unity.Mathematics;
using UnityEngine;
using UnityEngine.Rendering;
using UnityEngine.Rendering.Universal;
using UnityEngine.Experimental.Rendering;
public class TestComputerShaderFeature : ScriptableRendererFeature
{
public Material material;
public ComputeShader computeShader;
public RenderPassEvent renderPassEvent = RenderPassEvent.AfterRenderingOpaques;
CustomRenderPass m_ScriptablePass;
public override void Create()
{
if (computeShader != null || material != null)
{
m_ScriptablePass = new CustomRenderPass(computeShader, material);
m_ScriptablePass.renderPassEvent = renderPassEvent;
}
}
public override void AddRenderPasses(ScriptableRenderer renderer, ref RenderingData renderingData)
{
if (computeShader != null || material != null)
{
renderer.EnqueuePass(m_ScriptablePass);
m_ScriptablePass.Setup(renderer.cameraColorTarget);
}
}
protected override void Dispose(bool disposing)
{
if (m_ScriptablePass != null)
{
m_ScriptablePass = null;
}
}
class CustomRenderPass : ScriptableRenderPass
{
ComputeShader computeShader;
private RenderTargetIdentifier sourceID;
private RenderTextureDescriptor descriptor;
private RenderTargetHandle tempRTHandle;
private Material material;
int kernelIndex;
public CustomRenderPass(ComputeShader shader, Material material)
{
computeShader = shader;
this.material = material;
tempRTHandle.Init("_TempRT");
}
public void Setup(RenderTargetIdentifier sourceID)
{
this.sourceID = sourceID;
}
public override void OnCameraSetup(CommandBuffer cmd, ref RenderingData renderingData)
{
kernelIndex = computeShader.FindKernel("RandomColorKernel");
if (kernelIndex < 0)
{
Debug.LogError("Compute Shader kernel not found!");
return;
}
descriptor = renderingData.cameraData.cameraTargetDescriptor;
descriptor.enableRandomWrite = true; // 启用 UAV 标志,确保 RWTexture2D 可绑定
descriptor.msaaSamples = 1;
descriptor.dimension = TextureDimension.Tex2D;
descriptor.graphicsFormat = GraphicsFormat.R32G32B32A32_SFloat;
descriptor.depthBufferBits = 0;
}
public override void Execute(ScriptableRenderContext context, ref RenderingData renderingData)
{
CommandBuffer cmd = CommandBufferPool.Get("ComputeShaderPass");
cmd.GetTemporaryRT(tempRTHandle.id, descriptor);
cmd.SetComputeTextureParam(computeShader, kernelIndex, "DestTex", tempRTHandle.id);
var tgX = Mathf.CeilToInt(descriptor.width / 8f);
var tgY = Mathf.CeilToInt(descriptor.height / 8f);
cmd.DispatchCompute(computeShader, kernelIndex, tgX, tgY, 1);
cmd.Blit(tempRTHandle.id, renderingData.cameraData.renderer.cameraColorTarget, material);
context.ExecuteCommandBuffer(cmd);
CommandBufferPool.Release(cmd);
}
public override void OnCameraCleanup(CommandBuffer cmd)
{
if (tempRTHandle.id != 0)
{
cmd.ReleaseTemporaryRT(tempRTHandle.id);
tempRTHandle.id = 0;
}
}
}
}
派发线程组时,横竖两个维度都按「屏幕尺寸 ÷ 每组线程数」向上取整,保证覆盖每一个像素。对应的 Compute Shader 用像素坐标归一化后作为颜色输出:
#pragma kernel RandomColorKernel
RWTexture2D<float4> DestTex;
[numthreads(8, 8, 1)]
void RandomColorKernel(uint3 id : SV_DispatchThreadID)
{
int width;
int height;
// 获取目标纹理的尺寸
DestTex.GetDimensions(width, height);
DestTex[id.xy] = float4((float)id.x / (float)width, (float)id.y / (float)height, 0, 1.0); // 使用像素坐标生成颜色
}
更多实战应用:用 Compute Shader 在 GPU 上做剔除并驱动实例化渲染,见 GPU Instancing - DrawMeshInstancedIndirectGPU Instancing - DrawMeshInstancedIndirectDrawMeshInstancedIndirect 合批流程 DrawMeshInstancedIndirect 把"这帧画多少个实例"的决策权从 CPU 移到 GPU:CPU 不再遍历、剔除、统计数量,而是把全部候选实例、一段做剔除的 Compute、一个绘制参数缓冲交给 GPU,由 GPU 自己算出可见数量并据此绘制。实例化的基础原理见 [[GPU Instancing]],这里只讲 Indirect 多出来的部分。整条链路分三段。 一、CPU 准备数据 - 实例数据缓冲 instanceDataBuffer:一个 ComputeBuffer,按实例数量上限存下所有候选实例的属性(变换矩阵、颜色等),一次性上传显存。 - 绘制参数缓冲 argsBuffer:类型为 ComputeBufferType.IndirectArguments 的 ComputeBuffer,含 5 个 uint,布局由图形 API(DirectX/Vulkan)固定: | 下标 | 含义 | 取值 | |---|---|---| | args[0] | indexCountPerInstance;用 LDS 优化后处理带宽,见 BloomBloom原理 Bloom 做的事,是把画面里超过某个亮度的能量向周围像素扩散,让高光看起来更刺眼、像在发光。真实镜头里这是光学散射,实时渲染没法算,于是拿低分辨率模糊去近似:在缩小的图上做一次半径不大的模糊,等效于在全分辨率上套了一个很大的卷积核,采样次数却少得多。 整条管线就四步: 1. 过滤亮部——只留下够亮的部分,其余压掉; 2. 降采样——一路缩小成一座金字塔,每级顺带模糊; 3. 升采样——从最小一级往回放,逐级把模糊的下层叠回清晰的上层,光晕就是这么摊开的; 4. 合成——把最终的 bloom 纹理按强度加回原场景。 实现 过滤亮部 先用 clampMax 把个别爆亮的单点像素压下去——不然它在低分辨率下会闪(firefly)。再用一条 threshold + knee 的二次软阈值曲线做提亮,而不是硬切,避免高光边缘出现明暗断层 color = min(clampMax, sample(src, uv)) // 压 firefly b = max(color.r, color.g, color.b) // 亮度 的优化一节。