Contents

01ComputeShader

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细节都没有涉及。这些还需要进行详细深入的补充教学。