Unity-ComputeShader
ComputerShader是Unity中一个很实用且高性能的功能,本期分享一下ComputerShader的学习记录。最后会分享一个完全由computershader画出来的心形纹理,效果如下。
一.什么是ComputeShader?
Compute Shader(计算着色器)是一种在图形处理器(GPU)上执行通用计算任务的程序。它不直接用于渲染图形(即不负责将三角形绘制到屏幕上),而是专注于利用 GPU 强大的并行处理能力来高效地执行大量数据操作和数学计算。
一般CPU与GPU之间的信息传递容易出现性能瓶颈。ComputeShader(cs)的核心优势在于其高吞吐量和低延迟(对于并行任务)。它能将 CPU 上原本需要串行执行或效率低下的任务,卸载到 GPU 上进行并行加速。
Compute Shader 是 GPGPU(General-Purpose computing on Graphics Processing Units,利用图形处理器进行通用计算)的典型实现。
二.创建ComputeShader
创建一个新的ComputeShader(本质也是资源文件),顺便了解一下ComputeShader中的一些核心概念。
默认的ComputeShader
// 每个 #kernel 告诉要编译哪个函数;你可以有多个内核
#pragma kernel CSMain// 创建一个带有 enableRandomWrite 标志的 RenderTexture 并设置它
// 使用 cs.SetTexture
RWTexture2D<float4> Result;[numthreads(8,8,1)]
void CSMain (uint3 id : SV_DispatchThreadID)
{// TODO:在此处插入实际代码!Result[id.xy] = float4(id.x & id.y, (id.x & 15)/15.0, (id.y & 15)/15.0, 0.0);
}
1.pragma kernel CSMain
通过预编译指令定义了一个名称为CSMain的内核函数。当该内核被调度(Dispatch)时,核函数可以在 GPU 上被大量线程并行执行。
2.默认的RWTexture2D<float4> result是什么?
RWTexture2D是 HLSL中用于 Compute Shader 的一种可读写纹理类型。
(1)RW(Read-Write):表示这个纹理不仅可以像普通纹理一样被读取,还可以被 Compute Shader 的线程写入数据。这是它与Texture2D最核心的区别;
(2)Texture2D:表示它是一个二维纹理。
(3)<float4>:表示纹理中每个像素的颜色格式是float4。你可以根据需求使用自定义格式。
(4)Result:变量的名称。可以叫它任何你觉得有意义的名字。
RWTexture2D<float4> result不是必须的。 Compute Shader 不一定非要输出到RWTexture2D 它的输出可以是RWStructuredBuffer,AppendStructuredBuffer或没有显式输出。
三.ComputeShader的核心概念
1.kernel (内核 )
1.什么是kernel?
在Compute Shader 的语境中,内核 (Kernel) 可以理解为 GPU 上执行的单个计算任务或程序。它是 Compute Shader 文件的基本执行单元。一个 Compute Shader 文件可以包含一个或多个内核函数(用#pragma kernel kernelName来声明)。每个内核函数都封装了 GPU 上并行执行的特定计算逻辑。kernel定义的核函数被设计成可以在 GPU 上被大量线程并行执行。 当从 C# 脚本调度 Compute Shader 时,需要指定要运行哪个内核函数(如computeShader.Dispatch(computerShader.FindKernel("CSMain")))。
2.属性[numthreads(X,Y,Z)]
1.[numthreads(X,Y,Z)]是什么?
[numthreads(X,Y,Z)]是必须的,每个核函数前面都需要定义numthreads。这个属性告诉 GPU 每个线程组的“大小”。GPU 在执行 Compute Shader 时,会把你的计算任务分解成很多个线程组来处理。而每个线程组内部,又会进一步分解成更小的单位——线程。
每个线程组中可以执行的线程总数量=X*Y*Z;
[numthreads(X,Y,Z)]定义了这些线程是如何在线程组内部组织起来的。如果缺少这个属性,GPU 就不知道该如何分配和管理线程来执行你的内核代码,因此编译会失败。
关于线程组
在同一个线程组中的线程可以共享变量(内存),并能设置同一个线程组内的线程同步。
2.[numthreads(X,Y,Z)]怎么理解?
这里的 X, Y, Z 分别代表了在一个线程组 (Thread Group) 内部,线程 (Thread) 在各自维度上的数量。
X: 线程组在 X 维度上的线程数量。
Y: 线程组在 Y 维度上的线程数量。
Z: 线程组在 Z 维度上的线程数量。
这三个值都是正整数。例如[numthreads(8,8,1)]意味着这个线程组有 8 个线程宽 (X)、8 个线程高 (Y),以及 1 个线程深 (Z)。那么一个线程组总共有 8times8times1=64 个线程。
示意图来源:Unity3D Shader系列之Compute Shader基础及图像灰度化-CSDN博客
3.Compute Shader 内线程相关语义
SV代表 "System Value"(系统值)。(比较图形Shader里的SV_Target,这些带有sv前缀的变量是由 GPU 硬件或图形 API自动生成和填充的特殊值。它们提供了关于当前正在执行的着色器调用的一些上下文信息。)
示意图来源:Unity3D Shader系列之Compute Shader基础及图像灰度化-CSDN博客
(1)SV_DispatchThreadID
含义:当前线程在整个调度任务中的全局ID。
用途:这是最常用的 ID,通常直接用来索引到你的输入/输出缓冲区或纹理中的数据元素。
(2)SV_GroupID
含义:当前线程所属的线程组的ID。
用途:如果你需要对每个线程组进行一些独立的操作,或者根据线程组的 ID 来访问一些数据,就会用到它。
(3)SV_GroupThreadID
含义:当前线程在其所属线程组内部的局部ID。
范围:它的范围是从(0,0,0)到(X-1,Y-1,Z-1) 。
用途:常用于线程组内部的协作,例如访问groupshared内存,或者在线程组内部进行某种模式化的计算。
(4)SV_GroupIndex
含义:当前线程在其所属线程组内部的一维平面索引。
计算方式:SV_GroupIndex=
SV_GroupThreadID.z* (X*Y)
+SV_GroupThreadID.y*X
+SV_GroupThreadID.x
用途:当你将线程组内部的线程视为一个平铺的数组时,它很有用,比如在 groupshared
内存中进行一维索引。
(*-*)举个例子:
假如我们用 Compute Shader 来处理一张 16x16 像素的图片,比如上色。
// 每个线程组会处理 8x8 的像素块
[numthreads(8,8,1)]
void ProcessImage(uint3 globalID : SV_DispatchThreadID, // (1) 全局唯一 IDuint3 groupID : SV_GroupID, // (2) 线程组的全局 IDuint3 groupThreadID : SV_GroupThreadID, // (3) 线程组内的局部 IDuint groupIndex : SV_GroupIndex) // (4) 线程组内的一维局部索引
这张 16x16 的图片,因为每个线程组处理 8x8 的像素块,所以它被分成了 4 个线程组(一个 2x2 的网格):
现在,我们选择一个具体的像素,比如图片上的像素 (X=9, Y=5),看看处理这个像素的线程,它的四个 ID 分别是什么:
1.SV_DispatchThreadID (全局唯一 ID)
含义:是当前线程在整个 Compute Shader 调度任务中的绝对坐标。
例子:处理像素 (X=9, Y=5) 的线程:
它的SV_DispatchThreadID=(9,5,0) 。
2.SV_GroupID(线程组的全局 ID)
含义:这个 ID 告诉当前线程,它属于哪个线程组。
例子:处理像素 (X=9, Y=5) 的线程:
像素 X=9 在 8-15 范围内,Y=5 在 0-7 范围内。所以,这个像素属于 线程组 (1,0)。那么,这个线程的SV_GroupID就是(1,0,0) 。
3.SV_GroupThreadID (线程组内的局部 ID)
含义:这个 ID 告诉当前线程,它在自己所属的线程组内部的相对位置。可以理解为在自己的 8x8 小方块里的坐标。
范围:它总是从(0,0,0)开始,到(X-1,Y-1,Z-1) 结束。在我们例子中,范围是(0,0,0)到(7,7,0)。
例子:处理像素 (X=9, Y=5) 的线程:
其SV_GroupID是(1,0,0) 。
全局 X 是 9,这个线程组的起始 X 是 8。所以,局部 X = 9 - 8 = 1。
全局 Y 是 5,这个线程组的起始 Y 是 0。所以,局部 Y = 5 - 0 = 5。
那么这个线程的SV_GroupThreadID就是(1,5,0) 。
4.SV_GroupIndex(线程组内的一维局部索引)
含义:这是SV_GroupThreadID的一个一维扁平化版本。它把线程组内部的三维坐标转换成一个从 0 开始的单一数字。
计算方式:
SV_GroupIndex=
SV_GroupThreadID.z* (X*Y)
+SV_GroupThreadID.y*X
+SV_GroupThreadID.x
在[numthreads(8,8,1)]的例子中,Z维度是1。
例子:处理像素 (X=9, Y=5) 的线程:
其SV_GroupThreadID是(1,5,0) 。则SV_GroupIndex=5*8+1=41。
四.ComputeShader中常用的输出类型
Compute Shader 的目标是“计算并生成数据”。 调度 Compute Shader 是为了让它执行一些复杂的并行计算,并将结果写入到某个地方。这个“写入”的目标通常就是RWTexture2D或RWStructuredBuffer。下面总结ComputeShader中常用的输出类型:
1.RWStructuredBuffer<T>
RWStructuredBuffer<T>可以理解为 GPU 显存中的一张可读写的表格,或者说是一个结构体数组。每个“行”都是自定义数据结构T,每个“列”是该结构体中的成员变量。
核心特点
结构化数据: 你可以定义任意复杂的C#struct,并在 HLSL 中用struct映射它。
随机读写: Compute Shader 的每个线程都可以通过索引(指线程的相关语义)直接读取或写入缓冲区中的任何一个元素,就像访问普通数组一样。
双向通信:RW前缀表示它既可以读(Read)也可以写(Write)。这意味着 Compute Shader 既能从缓冲区中读取旧数据进行处理,也能将新数据写回同一个缓冲区,实现数据在 GPU 上的原地更新。
与 CPU 和其他 Shader 共享:
CPU ↔ GPU:在C#脚本中可以使用ComputeBuffer 从 CPU(SetData) 上传数据到 GPU,也可以从 GPU(GetData) 下载数据回 CPU(但GetData通常很慢,应尽量避免)。
GPU ↔ GPU:RWStructuredBuffer的数据可以被其他的着色器(如顶点着色器、片段着色器)读取,用于渲染。比如,Compute Shader 更新了粒子的位置,顶点着色器就能读取这些新位置来绘制粒子。
2.RWTexture2D<T>
RWTexture2D<T>可以理解为 GPU 显存中一张可读写的二维图像。它的每个“像素”(或称为“纹素”)都能存储你指定类型的数据。
核心特点
网格状数据: 纹理天生就是为二维网格数据设计的,非常适合图像、高度图、法线图等有明确 X、Y 坐标对应关系的数据。
随机读写: Compute Shader 的每个线程可以根据其全局线程ID直接读取或写入纹理中的任何一个纹素。
与 C# RenderTexture 对应: 在 C# 端RWTexture2D对应的是RenderTexture类型。你需要将 RenderTexture的enableRandomWrite设置为true,才能让 Compute Shader 写入它。
RenderTexture rt = new RenderTexture(size, size, 0);rt.enableRandomWrite = true; //启用写入功能rt.Create();
高性能图像操作: GPU 的硬件本身就是为纹理处理而优化的,所以对RWTexture2D的读写效率非常高。
结果可直接渲染: Compute Shader 处理完的RWTexture2D可以直接赋值给任何材质的纹理槽,然后被渲染到屏幕上。
一个画心形小例子
C#控制脚本
using System;
using UnityEngine;[ExecuteAlways]
public class HeartCSTex : MonoBehaviour
{public ComputeShader cs;public Material mat;public int size=512;public float heartScale_x=1;public float heartScale_y=1;public float yScaleBound=0.3f;public float animSpeed=1f;public float heartRim=1f;[Range(-1,1)]public float heartRimThread=1;int kernel;private RenderTexture rt;void Start(){kernel = cs.FindKernel("CSMain");rt = new RenderTexture(size, size, 0);rt.enableRandomWrite = true; rt.Create(); //cs对rt进行计算cs.SetTexture(kernel, "Result", rt); mat.SetTexture("_MainTex", rt);}private void Update(){rt = new RenderTexture(size, size, 0);cs.SetFloat("_TextureWidth",size);cs.SetFloat("_TextureHeight",size);cs.SetFloat("_HeartScale_X",heartScale_x);cs.SetFloat("_HeartScale_Y",heartScale_y);cs.SetFloat("_Time",Time.time*animSpeed);cs.SetFloat("_YScaleBound",yScaleBound);cs.SetFloat("_HeartRimThread",heartRimThread);cs.SetFloat("_HeartRim",heartRim);rt.enableRandomWrite = true; rt.Create();cs.SetTexture(kernel, "Result", rt); mat.SetTexture("_MainTex", rt);cs.Dispatch(kernel, size/8, size/8,1);}
}
ComputeShader
#pragma kernel CSMainRWTexture2D<float4> Result;float _TextureWidth;
float _TextureHeight;
float _HeartScale_X;
float _HeartScale_Y;
float _HeartRimThread;
float _HeartRim;
float _Time;
float _YScaleBound;[numthreads(8,8,1)]
void CSMain (uint3 id : SV_DispatchThreadID)
{// 结果在 -1 到 1 之间才能和心形公式的坐标范围匹配float x = (id.x / _TextureWidth - 0.5f) * 2.0f;float y = (id.y / _TextureHeight - 0.5f) * 2.0f;// 调整Y轴,因为在屏幕坐标系中Y通常是朝下的,而数学公式中Y是朝上的y = y * -(_HeartScale_Y + _YScaleBound*sin(_Time)); // 反转Y轴方向,并稍微拉伸一点,让心形更饱满x=x*_HeartScale_X;// 代入心形公式:(x^2 + y^2 - 1)^3 - x^2 y^3 = 0float heartEquation = pow(x*x + y*y - 1.0f, 3.0f) - x*x * pow(y, 3.0f);// 设置一个阈值,判断像素是否在心形内部float4 finalColor;if (heartEquation < _HeartRimThread) // 如果计算结果小于阈值,说明是心形的一部分{// 还可以根据 heartEquation 的值做一些边缘柔化效果// 例如,让边缘颜色渐变:lerp(_HeartColor, _BackgroundColor, saturate(heartEquation / threshold));finalColor = float4(1,0,0,1);}else // 否则就是背景{finalColor = float4(1,1,1,1);//_BackgroundColor;}// 将最终颜色写入结果纹理Result[id.xy] = finalColor;
}
创建一个材质,Shader选择默认有的Unlit/Texture(选哪个Shader不重要,主要是要有属性_MainTex不然C#脚本里找不到),后面computerShader会把心形写入一张RT,进而再将RT作为这个材质的主纹理。
准备的差不多了,挂载控制脚本,拖入ComputerShader和材质引用就应该有效果了。
最终效果
本篇完