ComputeShader
对应教程网址
https://catlikecoding.com/unity/tutorials/basics/compute-shaders/
本篇主要用ComputeShader来进行大批量物体渲染。在之前的教程中大佬用一个个Cube来当一个像素点(或者说体素点)来进行三维程序运动模拟。基于Unity自带的Update接口方式,实现很多程序化造型和运动。在面对大量物体运动更新,渲染的时候,这种方式非常慢。
在这种程序化造型运动过程中,所有的物体运动是相对独立的,所以很适合并行化运算。这里所讲述的就是基于ComputeShader,用显卡来并行计算每个物体的位置,同时直接渲染出对应物体的方式。
关于ComputerShader的原理可以见很多图形学书籍,例如
【Unity着色器圣经】[https://zhuanlan.zhihu.com/p/645676077]
【UnityShaderBible与ComputeShader学习】[https://zhuanlan.zhihu.com/p/647520144]
【ComputeShader官方文档】[https://docs.unity3d.com/Manual/class-ComputeShader.html]
简单来说,ComputeShader允许使用GPU的计算单元来批量并行的进行相同计算。GPU作为SIMD并行计算结构,会将很多计算单元划分成组的形式,以SIMD形式并行计算。这样一组被称之为Warp,通常来说NVDIA显卡是32线程为一个Warp,ATI显卡是64线程为一个Warp。
目标是用大量Cube模拟一个Wave波形图,这里的思路大致如下图:
使用ComputeShader
使用ComputeShader来进行计算工作,只需要在Unity中右键–Create–Shader–ComputeShader即可创建一个ComputeShader文件。其中最关键的部分就是定义每个计算单元上的计算函数。这个可以通过如下方式来声明
1
2
3
4
5
6
7
8
9
|
// 定义核心计算函数 为下面的FunctionKernel函数
#pragma kernel FunctionKernel
// 每一组中计算矩阵定义,相当于一组以8*8的方式运行,可以参看下面说明
[numthreads(8, 8, 1)]
void FunctionKernel(uint3 id : SV_DispatchThreadID)
{
// ...
}
|
关于计算函数的参数语义与Attribute语义可以参考下图:
(来自于D3D11官网[https://learn.microsoft.com/en-us/windows/win32/direct3dhlsl/sm5-attributes-numthreads])
这个是D3D11中Dispatch接口运作的解释。
- Dispatch函数接收三个参数,以三维的形式表示要发出的线程组的个数。每个线程组以三元组标记。
- 每一个线程组也以三维形式划分,其大小用numthread来表示。其中每一个线程也以三元组标记。
SV_GroupThreadID表示每一个线程组中,线程的位置。
SV_GroupID表示线程组的位置标记。
SV_DispatchThreadID相当于全局情况下,当前线程的位置,可以通过
$SV_{GroupID}*numthreads+SV_{GroupThreadID}$计算出一个三元组来标记。相当于将所有线程铺平后线程的位置。
图中示例表示在numthreads(10,8,3)情况下,调用Dispatch(5,3,2)时的运行情况。Dispatch(5,3,2)相当于发起$5*3*2=30$个线程组。其中$(2,1,0)$标记的线程组中的$(7,5,0)$号线程,其对应的SV_DispatchThreadID即$(27,12,0)$。此即上面ComputeShader计算函数中传入的参数id的值。
计算Wave波形图,相当于给定一个二维的位置$(i,j)$计算出对应的三维控件位置$(x(i,j),y(i,j),z(i,j))$。所以这里传入的uint3 id : SV_DispatchThreadID刚好可以作为二维的位置标记。
所以这里ComputeShader中的Wave的波形计算可以由如下方式计算
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
|
// 外部可设置参数 每一个Cube大小(图像步长) 累计时间
float _step, _time;
RWStructuredBuffer<float3> _position;//一个可读可写的缓冲区,用于传递出计算后位置的数组
// 将线程组标记(i,j)映射到真实空间位置(-1,-1)到(1,1)矩形内
float2 GetUV(uint3 id)
{
return id.xy * _step - 1.0;
}
// Wave计算函数,给如二维位置 程序造型给出随时间变化的波形效果 即y轴的sin波动
float3 Wave(float u, float v, float t)
{
float3 p = 0.0;
p.x = u;
p.y = sin(PI * (u + v + t));
p.z = v;
return p;
}
// 将计算出的3维位置 放入位置缓存数组中 传递给外面做后续计算
void SetPosition(uint3 id, float3 position)
{
if (id.x < _resolution && id.y < _resolution)
{
_position[id.x + id.y * _resolution] = position;
}
}
[numthreads(8, 8, 1)]
void FunctionKernel(uint3 id : SV_DispatchThreadID)
{
float2 uv = GetUV(id);
SetPosition(id, Wave(uv.x, uv.y, _time));
}
|
现在来看ComputeShader中计算就非常明确。以线程ID标记当前位置,然后用波函数计算出位置并传递出去。
而外部脚步要使用这个ComputeShader来进行计算,则是需要声明ComputeShader变量赋值对应Shader并调用Dispatch来执行。具体来说如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
|
public class MonoComputeShader : MonoBehaviour
{
//申明Computershader变量,后面由Inspector上面赋值对应ComputeShader
public ComputeShader compute_shader;
// 用于传递位置数组的buff
public ComputeBuffer position_buff;
// Shdaer要传递对应参数名称 需要把名称转换为对应ID来使用
private static int position_id = Shader.PropertyToID("_position");
private static int resolution_id = Shader.PropertyToID("_resolution");
private static int step_id = Shader.PropertyToID("_step");
private static int time_id = Shader.PropertyToID("_time");
public void OnEnable()
{
//初始化位置数组的大小 大小为计算的矩形空间大小
//ComputeBuffer可以存储任意类型信息,只关心分配大小,其以每个元素使用byte数计数。
//因为每个元素要存储三个float位置信息,所以使用3*4byte大小
position_buff = new ComputeBuffer(resolution * resolution, 3 * 4);
}
public void UpdateFunctionOnGPU()
{
float step = 2f / resolution;
// 设置ComputeShader中对应的参数数值
compute_shader.SetInt(resolution_id, resolution);
compute_shader.SetFloat(step_id, step);
compute_shader.SetFloat(time_id, Time.time);
//设置编号0为KernelFunction使用关联的
compute_shader.SetBuffer(0, position_id, position_buff);
//Dispatch启动computshader
//第一个为目标编号的KernelFunction 后面三个参数对应启动的线程组个数
int groups = Mathf.CeilToInt(resolution / 8);
compute_shader.Dispatch(0, groups, groups, 1);
}
}
|
然后将Mono脚本挂在一个Gameobject上,将对应的ComputeShader挂在上面即可。
这个时候position_buff中已经存放了所有计算好的位置信息,下面要用正常的渲染管线流程接受数据,批量渲染出Cube。
Shader渲染
Catlike大佬教程中同时有讲默认渲染管线与URP渲染管线下实现方式。先看一下默认管线下Shader的实现,两边的实现原理大致是一样的,但因为管线方式略有不同。
因为要依赖于ComputeShader填充的buffer,需要把Shader的目标等级提升到4.5。(这个不是严格需要,但是可以指明我们需要ComputeShader支持。)
1
2
3
4
|
SubShader
{
#pragma target 4.5
}
|
程序化渲染类似于GPU instancing,可以指定程序化进行的函数操作。这里Shader进行的操作很简单。就是把位置信息设置到渲染的物体Cube上面。我们知道,对于每个物体来说,其位置信息可以由其变换矩阵来表示,最后经过相机空间,裁剪空间等等变换的屏幕上。所以这里实际操作就是根据position_buff信息来设置物体的世界空间下变换矩阵,使得目标渲染在预期位置。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
|
SubShader
{
// 指定程序化instancing函数为 ConfigureProcedural
#pragma instancing_options assumeuniformscaling procedural:ConfigureProcedural
// 计算用的参数如步长 位置缓存
float _step;
// UNITY_PROCEDURAL_INSTANCING_ENABLED表示支持程序化Instancing技术
#if defined(UNITY_PROCEDURAL_INSTANCING_ENABLED)
StructuredBuffer<float3> _position;
#endif
void ConfigureProcedural(){
#if defined(UNITY_PROCEDURAL_INSTANCING_ENABLED)
// 程序化赋值物体的世界空间矩阵信息
// unity_InstanceID物体实例的标识ID
// unity_ObjectToWorld为ShaderLab中约定的当前物体实例的时间空间矩阵
// 设置位置参数为position_buffer中的对应值
// 缩放则为步长大小,这样每个Cube不会重叠
float3 position = _position[unity_InstanceID];
unity_ObjectToWorld = 0.0;
unity_ObjectToWorld._m03_m13_m23_m33 = float4(position,1.0);
unity_ObjectToWorld._m00_m11_m22 = _step;
#endif
}
}
|
之后使用该Shader,创建一个Material并在Mono脚本中调用即可。而在Mono中启用Instancing绘制需要如下调用:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
|
// 每帧Update调用UpdateFunctionOnGPU来更新绘制
private void Update()
{
UpdateFunctionOnGPU();
}
public void UpdateFunctionOnGPU()
{
// ComputeShader计算出位置信息
......
// Material设置参数 将position_buffer跟instanceshader关联
material.SetBuffer(position_id, position_buff);
material.SetFloat(step_id, step);
// 调用Graphic接口 以目标mesh和material来批量绘制Cube
var bounds = new Bounds(Vector3.zero, Vector3.one * (2f + 2f / resolution));
Graphics.DrawMeshInstancedProcedural(mesh, 0, material, bounds, position_buff.count);
}
|
现在启动游戏就可以看到绘制后的Wave效果。
可以看到即便创建$200*200$的Cube绘制依然很流程。
URP管线下比较方便的是使用ShaderGraph。但是ShaderGraph并不直接支持程序化定制绘制。但是支持添加自定义Code节点,可以通过自定义Code节点的方式来将类似默认管线中的代码,注入到ShaderGraph中去。而自定义节点,则需要HLSL文件。Unity并不支持右键Create直接创建HLSL文件,但是可以直接复制一个Shader文件更改扩展名为hlsl来实现。
为了添加ShaderGraph中节点,添加一个新的hlsl文件。并且将上面的ComputeShader计算代码放入其中,其文件内容如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
|
// Compute计算需要的函数部分逻辑
float _step;
#if defined(UNITY_PROCEDURAL_INSTANCING_ENABLED)
StructuredBuffer<float3> _position;
#endif
void ConfigureProcedural(){
#if defined(UNITY_PROCEDURAL_INSTANCING_ENABLED)
float3 position = _position[unity_InstanceID];
unity_ObjectToWorld = 0.0;
unity_ObjectToWorld._m03_m13_m23_m33 = float4(position,1.0);
unity_ObjectToWorld._m00_m11_m22 = _step;
#endif
}
// 为了让ShaderGraph识别添加接口输入输出函数
// 这里的in out 就是针对ShaderGrpah中接口的描述 对于节点图中输入输出来说我们没有做任何更改所以直接把in赋值给out
void ShaderGraphFunction_float (float3 In, out float3 Out) {
Out = In;
}
void ShaderGraphFunction_half (half3 In, out half3 Out) {
Out = In;
}
|
然后可以创建一个ShaderGraph文件。在结点图中右键创建一个CustomFunction节点。如下设置节点数据:
- 设置节点类型为File。
- 设置节点的Source为刚刚创建的hlsl文件。
- Name部分输入hlsl文件中约定的接口函数名称,即上面的ShaderGraphFunction。
- 然后添加inputs和outputs分别为Vector3,这对应函数参数中的float3。因为我们主要修改目标物体的世界坐标,属于对顶点的世界position进行修改。
对比默认管线下的流程,我们可以看到还少一步:
- 指定渲染管线level,指明使用ConfigureProcedural作为程序化渲染函数。
这也可以通过CustomFunction设置,其设置如下:
- 设置节点类型为String。标识直接用下面Body内的内容
- Name可以设置为一个合适的名字。
- Body设置为要嵌入的代码内容。其中前面两段即指定Level以及指定程序化函数宏。而最后一段则是标识输入输出端口设置,这部分名称需要跟上面inputs和outputs中添加接口对应。表示直接将In端口赋值到Out端口。
最后使用URP下shader的Material一样可以看到程序化渲染后的效果。
总结
这篇文章主要介绍一个ComputeShader入门。中间忽略了很多细节,只是抽出了大概的脉络来进行。细节部分可以直接看原教程。同时很多技术,例如ComputeShader细节,GPUInstance细节都没有涉及。这些还需要进行详细深入的补充教学。