Deep Read

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 计算从提交线程到实际执行,大致经历这几步:

  1. 开发者提交大量并行线程(如逐像素、逐矩阵元素处理)。
  2. 线程被分组为线程块(NVIDIA 叫 thread block,AMD 叫 work group),每个线程块分配到一个 SM 或 CU。
  3. SM/CU 内部再把线程块拆成多个 warp(32 线程)或 wavefront(64 线程)。
  4. 这些 warp/wavefront 被调度到 SP/SIMD 阵列上,以 SIMD 方式同步并行执行。
  5. 调度器不断在就绪的 warp/wavefront 之间切换,用计算掩盖访存延迟。

NVIDIA 与 AMD 的术语对照如下:

层级NVIDIAAMD
基本执行单元SP / CUDA CoreSIMD 单元
调度单元SMCU
调度批次Warp(32 线程)Wavefront(64 线程)
线程集合Thread BlockWork 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) 是内核入口。参数 iduint3 类型(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) // 亮度 的优化一节。