计算着色器
渲染100万立方体
原文地址:https://catlikecoding.com/unity/tutorials/basics/compute-shaders/
存储位置存储在计算缓冲区。 让GPU做大部分工作。 逐步绘制多个立方体。 复制整个函数库GPU上。
这是关于学习和使用的Unity的basics第五系列教程。这一次,我们将使用计算着色器来显著提高图形的分辨率。
本教程使用Unity 2020.3.6f1制作。
1. 将工作转移到GPU
图像分辨率越高,CPU和GPU你需要做的工作越多,比如计算位置和渲染立方体。点的数量等于分辨率的平方,所以加倍分辨率会显著增加工作量。当分辨率为100时,我们可以达到60FPS,但是我们能做多少呢?若遇到瓶颈,能否用不同的方法突破?
1.1 200分辨率
让我们先将Graph最大分辨率从100提高到200,看看我们得到了什么样的性能。
[SerializeField, Range(10, 200)] int resolution = 10;
我们现在要渲染4万个点。在我的例子中,BRP平均帧率降至10FPS, URP平均帧率降至15FPS。这对于稳定的体验来说太低了。
通过分析构建,我们可以发现一切都需要大约四倍的时间,这是合理的。
1.2 GPU图形
排序,批处理,然后发送4万个转换矩阵GPU需要很多时间。一个矩阵由16个浮点数组成,每个浮点数4个字节,每个矩阵共64个B。对于40000个点,每个画点都需要256万字节(约2000字节).44 mb)复制到GPU上。URP每帧需要做两次,一次是阴影,一次是常规几何。BRP至少这样做三次,因为它有额外的限深通道,除了主方向的光,每个光都必须再通过一次。
MiB是什么? 由于计算机硬件采用二进制数寻址内存,因此以2的幂而不是10的幂来划分。MiB是mebibyte的后缀,即2 ^ 20 = 1,024 ^2 = 1,048,576字节。这最初被称为兆字节(以mb表示),但现在应该表示10 ^ 符合官方定义的100万字节。然而,MB、GB它仍然经常使用,而不是MiB、GiB等。
通常,CPU和GPU最好尽量减少通信和数据传输之间的数量。因为如果数据只存在于数据中,我们只需要点的位置来显示它们GPU端,那将是最理想的。这节省了大量的数据传输。CPU位置不能再计算了,GPU它必须被取代。幸运的是,它非常适合这项任务。
让GPU计算位置需要不同的方法。为便于比较,我们将保留当前图表,并创建新图表。复制Graph C#资产文件被重命名为GPUGraph。从新类中删除pointPrefab和points字段。然后移除它Awake, UpdateFunction和UpdateFunctionTransition方法。我只将删除的代码标记为新类别,而不是将所有内容标记为新代码。
using UnityEngine; public class GPUGraph : MonoBehaviour {
//[SerializeField] //Transform pointPrefab; [SerializeField, Range(10, 200)] int resolution = 10; [SerializeField] FunctionLibrary.FunctionName function; public enum TransitionMode {
Cycle, Random
}
[
SerializeField
]
TransitionMode transitionMode
= TransitionMode
.Cycle
;
[
SerializeField, Min(0f)
]
float functionDuration
=
1f
, transitionDuration
=
1f
;
//Transform[] points;
float duration
;
bool transitioning
;
FunctionLibrary.FunctionName transitionFunction
;
//void Awake () { … }
void Update
(
)
{
…
}
void PickNextFunction
(
)
{
…
}
//void UpdateFunction () { … }
//void UpdateFunctionTransition () { … }
}
然后删除在Update结束时调用现在缺失的方法的代码。
void Update () {
…
//if (transitioning) {
// UpdateFunctionTransition();
//}
//else {
// UpdateFunction();
//}
}
我们的新GPUGraph组件是一个删除版的Graph,它公开了相同的配置选项,只是少了预制件。它包含从一个函数转换到另一个函数的逻辑,但除此之外不做任何事情。用这个组件创建一个游戏对象,分辨率为200,设置循环为瞬间转换。停用原来的图形对象,以便只有GPU版本保持激活。
1.3 计算缓冲区
为了在GPU上存储位置,我们需要为它们分配空间。为此,我们创建了一个ComputeBuffer对象。向GPUGraph添加一个位置缓冲区字段,并通过调用new ComputeBuffer()在一个新的Awake方法中创建对象,该方法被称为构造函数方法。它的工作原理类似于分配一个新数组,但针对的是一个对象或结构。
ComputeBuffer positionsBuffer;
void Awake () {
positionsBuffer = new ComputeBuffer();
}
我们需要将缓冲区的元素数量作为参数传递,也就是分辨率的平方,就像Graph的位置数组一样。
positionsBuffer = new ComputeBuffer(resolution * resolution);
计算缓冲区包含任意的非类型化数据。我们必须通过第二个参数以字节为单位指定每个元素的确切大小。我们需要存储3D位置向量,它由三个浮点数组成,所以元素大小是3乘以4字节。因此,40000个位置将需要0.48MB或大约0.46MiB的GPU内存。
positionsBuffer = new ComputeBuffer(resolution * resolution, 3 * 4);
这为我们提供了一个计算缓冲区,但这些对象不能在热重加载时存活,这意味着如果我们在播放模式下更改代码,它将消失。我们可以通过用OnEnable方法替换Awake方法来处理这个问题,每当组件被启用时,OnEnable方法就会被调用。这在它醒来后立即发生——除非它被禁用——并且在热重新加载完成后也会发生。
void OnEnable () {
positionsBuffer = new ComputeBuffer(resolution * resolution, 3 * 4);
}
除此之外,我们还应该添加一个伴生的OnDisable方法,该方法在组件被禁用时被调用,在图形被销毁和热重新加载之前也会发生这种情况。通过调用它的release方法,让它释放缓冲区。这表明被缓冲区请求的GPU内存可以立即被释放。
void OnDisable () {
positionsBuffer.Release();
}
void OnDisable () {
positionsBuffer.Release();
}
因为在此之后我们将不再使用这个特定的对象实例,所以显式地将字段设置为引用null是一个好主意。这使得如果我们的图形在播放模式中被禁用或销毁,对象在下一次运行时被Unity的内存垃圾收集进程回收成为可能。
void OnDisable () {
positionsBuffer.Release();
positionsBuffer = null;
}
如果我们不显式地释放缓冲区会发生什么? 当垃圾回收器回收该对象时,如果没有任何东西持有该对象的引用,则该对象最终将被释放。但这种情况的发生是任意的。最好尽快显式地释放它,以避免内存阻塞。
1.4 计算着色器
为了计算GPU上的位置,我们必须为它写一个脚本,它是一个计算着色器。通过Assets / Create / Shader / Compute Shader创建。它将成为我们的FunctionLibrary的GPU等效物,所以也把它命名为FunctionLibrary。虽然它被称为着色器,并使用HLSL语法,但它的功能是一个通用程序,而不是一个用于渲染事物的常规着色器。因此,我将资产放在Scripts文件夹中。
打开资产文件并删除其默认内容。一个计算着色器需要包含一个被称为内核的主函数,通过#pragma kernel指令后跟一个名称来表示,比如我们的表面着色器的#pragma surface。将此指令添加为第一行,使用FunctionKernel的名字作为当前唯一一行。
#pragma kernel FunctionKernel
在指令下面定义函数。它是一个void函数,最初没有参数。
#pragma kernel FunctionKernel
void FunctionKernel () {
}
1.5 计算线程
当GPU被要求执行一个计算着色器功能时,它将其工作划分为组,然后安排它们独立并行运行。每个组依次由许多执行相同计算但使用不同输入的线程组成。我们必须通过向内核函数添加numthreads属性来指定每个组应该有多少线程。它需要三个整数参数。最简单的选项是对所有三个参数使用1,这使得每个组只运行一个线程。
[numthreads(1, 1, 1)]
void FunctionKernel () {
}
GPU硬件包含计算单元,它们总是在lockstep中运行特定数量的线程。这些被称为warps或wavefronts。如果一个组的线程数量少于warp大小,一些线程将运行空闲,浪费时间。如果线程的数量超过了线程的大小,那么GPU将每组使用更多的warp。通常64个线程是一个很好的默认值,因为它匹配AMD gpu的warp大小,而NVidia gpu的warp大小是32,所以NVidia每组将使用两个warp。实际上,硬件更复杂,可以用线程组做更多的事情,但这与我们的简单图无关。
numthreads的三个参数可用于以一维、二维或三维的方式组织线程。例如,(64,1,1)为我们提供了单维度的64个线程,而(8,8,1)为我们提供了相同数量的线程,但以2D 8×8正方形网格的形式呈现。当我们基于2D UV坐标定义点时,让我们使用后一种选项。
[numthreads(8, 8, 1)]
每个线程都由三个无符号整数组成的向量来标识,我们可以通过给函数添加一个uint3参数来访问它们。
void FunctionKernel (uint3 id) {
}
什么是无符号整数? 它是一个没有符号指示的整数,因此它是无符号的。无符号整数要么是零,要么是正的。因为无符号整数不需要使用位来表示符号,它们可以存储更大的值,但这通常不重要。
我们必须显式地指出这个参数是用于线程标识符的。我们通过在参数名后面加上冒号再加上SV_DispatchThreadID着色器语义关键字来实现。
void FunctionKernel (uint3 id: SV_DispatchThreadID) {
}
1.6 UV坐标系
如果我们知道图的步长,我们可以将线程标识符转换为UV坐标。为它添加一个名为_Step的计算机着色器属性,就像我们在表面着色器中添加_Smoothness一样。
float _Step;
[numthreads(8, 8, 1)]
void FunctionKernel (uint3 id: SV_DispatchThreadID) {
}
然后创建一个GetUV函数,该函数接受线程标识符作为参数,并以float2的形式返回UV坐标。当循环通过点时,我们可以使用与Graph中相同的逻辑。取标识符的XY分量,加0.5,乘以步长,然后减去1。
float _Step;
float2 GetUV (uint3 id) {
return (id.xy + 0.5) * _Step - 1.0;
}
1.7 设置位置
为了存储一个位置,我们需要访问位置缓冲区。在HLSL中,计算缓冲区被称为结构化缓冲区。因为我们必须写入它,所以我们需要启用读写的版本,也就是RWStructuredBuffer。添加一个名为_Positions的着色器属性。
RWStructuredBuffer _Positions;
float _Step;
在这种情况下,我们必须指定缓冲区的元素类型。位置是float3类型,直接写在RWStructuredBuffer后面的尖括号之间。
RWStructuredBuffer<float3> _Positions;
为了存储一个点的位置,我们需要根据线程标识符给它分配一个索引。我们需要知道这个图形的分辨率。所以添加一个_Resolution着色器属性,使用uint类型来匹配标识符的类型。
RWStructuredBuffer<float3> _Positions;
uint _Resolution;
float _Step;
然后创建一个SetPosition函数来设置位置,给定一个标识符和要设置的位置。对于索引,我们将使用标识符的X分量加上它的Y分量乘以图形分辨率。通过这种方式,我们将2D数据按顺序存储在一个1D数组中。
float2 GetUV (uint3 id) {
return (id.xy + 0.5) * _Step - 1.0;
}
void SetPosition (uint3 id, float3 position) {
_Positions[id.x + id.y * _Resolution] = position;
}
我们必须注意的一件事是,我们的小组都计算一个包含8×8点的网格。如果图像的分辨率不是8的倍数,那么我们将以组的一行和一列来计算一些越界的点。这些点的索引要么落在缓冲区之外,要么与有效索引冲突,这将破坏我们的数据。
只有当标识符X和Y分量都小于分辨率时,才可以通过存储它们来避免无效位置。
void SetPosition (uint3 id, float3 position) {
if (id.x < _Resolution && id.y < _Resolution) {
_Positions[id.x + id.y * _Resolution] = position;
}
}
1.8 Wave函数
我们现在可以在FunctionKernel中获得UV坐标,并使用我们创建的函数设置位置。首先使用0作为位置。
[numthreads(8, 8, 1)]
void FunctionKernel (uint3 id: SV_DispatchThreadID) {
float2 uv = GetUV(id);
SetPosition(id, 0.0);
}
我们最初只支持Wave函数,它是库中最简单的函数。要让它变成动画,我们需要知道时间,所以添加一个_Time属性。
float _Step, _Time;
然后从FunctionLibrary类中复制Wave方法,插入到FunctionKernel上面。要将其转换为HLSL函数,请删除公共静态限定符,将Vector3替换为float3,将Sin替换为Sin。
float3 Wave (float u, float v, float t) {
float3 p;
p.x = u;
p.y = sin(PI * (u + v + t));
p.z = v;
return p;
}
唯一缺少的是PI的定义。我们将通过为它定义一个宏来添加它。这是通过在数字后面写入#define PI来完成的,为此我们将使用3.14159265358979323846。这比一个浮点数的值要精确得多,但是我们把它留给着色器编译器来使用一个适当的近似。
#define PI 3.14159265358979323846
float3 Wave (float u, float v, float t) {
… }
现在使用Wave函数在FunctionKernel中计算位置。
void FunctionKernel (uint3 id: SV_DispatchThreadID) {
float2 uv = GetUV(id);
SetPosition(id, Wave(uv.x, uv.y, _Time));
}
1.9 调度一个计算着色内核
我们有一个计算和存储图中点位置的核函数。下一步是在GPU上运行它。GPUGraph需要访问计算着色器来实现这一点,所以添加一个可序列化的ComputeShader字段到它,然后将我们的资产挂接到组件上。
[SerializeField]
ComputeShader computeShader;
我们需要设置计算着色器的一些属性。为了做到这一点,我们需要知道Unity为它们使用的标识符。这些整数可以通过用名称字符串调用Shader.PropertyToID来获取。这些标识符是按需声明的,并且在应用程序或编辑器运行时保持不变,因此我们可以直接将标识符存储在静态字段中。从_Positions属性开始。
static int positionsId = Shader.PropertyToID("_Positions");
我们永远不会更改这些字段,我们可以通过向它们添加 readonly 限定符来表示。除了指明字段的意图之外,这还指示编译器在我们在其他地方对字段赋值时产生错误。
static readonly int positionsId = Shader.PropertyToID("_Positions");
难道我们不应该用readonly标记 FunctionLibrary.functions吗? 虽然这很有意义,但readonly不适用于引用类型,因为它只强制字段值本身不改变。对象(在本例中是数组)本身仍然可以修改。因此,它会阻止分配一个完全不同的数组,但不会阻止改变它的元素。我更喜欢只对原始类型(如int)使用readonly。
还要存储_Resolution、_Step和_Time的标识符。
static readonly int
positionsId = Shader.PropertyToID("_Positions"),
resolutionId = Shader.PropertyToID("_Resolution"),
stepId = Shader.PropertyToID("_Step"),
timeId = Shader.PropertyToID("_Time");
接下来,创建一个UpdateFunctionOnGPU方法,计算步长,设置分辨率,步长和计算着色器的时间属性。调用它的SetInt来对resolution赋值,调用SetFloat赋值其他两个属性,使用标识符和值作为参数。
void UpdateFunctionOnGPU () {
float step = 2f / resolution;
computeShader.SetInt(resolutionId, resolution);
computeShader.SetFloat(stepId, step);
computeShader.SetFloat(timeId, Time.time);
}
着色器的分辨率属性不是uint吗? 是的,但只有一种方法可以设置常规整数,而不是无符号整数。这很好因为正int值等同于uint值。
我们还必须设置位置缓冲区,它不复制任何数据,而是将缓冲区链接到内核。这是通过调用SetBuffer来完成的,它的工作原理与其他方法一样,只是它需要一个额外的参数。它的第一个参数是内核函数的索引,因为一个计算着色器可以包含多个内核,并且缓冲区可以链接到特定的内核。我们可以通过在计算着色器上调用FindKernel来获得内核索引,但是我们的单内核索引总是0,所以我们可以直接使用那个值。
computeShader.SetFloat(timeId, Time.time);
computeShader.SetBuffer(0, positionsId, positionsBuffer);
在设置缓冲区之后,我们可以运行我们的内核,通过在计算着色器上调用带有四个整数参数的Dispatch。第一个是内核索引,其他三个是要运行的组数量,同样按维度划分。对所有维度使用1意味着只计算第一组8×8位置。
computeShader.SetBuffer(0, positionsId, positionsBuffer);
computeShader.Dispatch(0, 1, 1, 1);
由于我们的固定的8×8组大小,我们在X和Y维度中需要的组数量等于分辨率除以8(四舍五入)。我们可以通过执行float除法并将结果传递给Mathf.CeilToInt来实现这一点。
int groups = Mathf.CeilToInt(resolution / 8f);
computeShader.Dispatch(0, groups, groups, 1);
为了最终在更新结束时运行我们的内核调用UpdateFunctionOnGPU。
void Update () {
…
UpdateFunctionOnGPU();
}
现在我们正在计算游戏模式下每一帧的所有图形位置,尽管我们并没有注意到这一点,也没有对数据做任何操作。
2. 程序化绘制
有了GPU上可用的位置,下一步是绘制点,不需要从CPU发送任何转换矩阵到GPU。因此,着色器将不得不从缓冲区中检索正确的位置,而不是依赖于标准矩阵。
2.1 绘制许多网格
因为这些位置已经存在于GPU中,我们不需要在CPU端跟踪它们。我们甚至不需要游戏对象。相反,我们将指示GPU使用特定材质多次绘制特定网格,通过单个命令。要配置绘制的内容,需要添加可序列化的Material和Mesh字段到GPUGraph。我们将首先使用现有的Point Surface材料,我们已经有了用BRP绘制点。对于网格,我们将使用默认立方体。
[SerializeField]
Material material;
[SerializeField]
Mesh mesh;
程序绘制通过调用Graphics.DrawMeshInstancedProcedural,以一个网格,子网格索引,和材质作为参数。子网格索引是用于一个网格由多个部分组成时,这不是我们的情况,所以我们使用索引0。在UpdateFunctionOnGPU的末尾执行此操作。
void UpdateFunctionOnGPU () {
…
Graphics.DrawMeshInstancedProcedural(mesh, 0, material);
}
我们不应该使用DrawMeshInstancedIndirect吗? DrawMeshInstancedIndirect方法是有用的,当你不知道有多少实例绘制在CPU端,而不是通过一个缓冲区提供计算着色器的信息。
因为这种绘制方式不使用游戏对象,Unity不知道绘制在场景的什么地方。我们必须通过提供一个边界框作为附加参数来表示这一点。这是一个轴对齐的框,它表示我们所画物体的空间边界。Unity使用这一点来决定绘图是否可以跳过,因为它最终会出现在摄像机的视野之外。这就是所谓的截锥剔除。不再是计算每个点的边界而是一次计算整个图的边界。这对于我们的图来说是很好的,因为我们的想法是我们从整体上看待它。
我们的图形位于原点,这些点应该保持在一个大小为2的立方体内。我们可以通过调用Bounds构造函数方法来为其创建边界值,Vector3.zero,Vector3.one乘以2作为参数。
var bounds = new Bounds(Vector3.zero, Vector3.one * 2f);
Graphics.DrawMeshInstancedProcedural(mesh, 0, material, bounds);
但是点也有大小,其中一半的点可以向各个方向戳出边界。所以我们也应该增大边界。
var bounds = new Bounds(Vector3.zero, Vector3.one * (2f + 2f / resolution));
我们必须提供给DrawMeshInstancedProcedural的最后一个参数是应该绘制多少个实例。这应该与position缓冲区中的元素数量相匹配,我们可以通过它的count属性来检索。
Graphics.DrawMeshInstancedProcedural(
mesh, 0, material, bounds, positionsBuffer.count
);
为什么进入游戏模式Unity会完全卡住? 如果发生这种情况,你已经遇到了Unity 2020的bug行为,导致严重的编辑器滞后。在进入游戏模式后,如果它仍然卡住,那么将应用的焦点从Unity移走再转移到Unity上会有所帮助。这可能会使它活动。重新启动编辑器也可以解决这个问题。
当进入游戏模式时,我们将看到一个单色的单位立方体位于原点。每个点渲染一个相同的立方体,但是都使用相同的变换矩阵所以它们都是重叠的。性能比以前好了很多,因为几乎不需要将数据复制到GPU,所有的点都是通过一个绘制调用绘制的。此外,Unity并不需要对每个点进行剔除。它也不会根据视距深度对点进行排序,而通常它会这样做,这样离相机最近的点就会先被绘制出来。深度排序使不透明的几何图形的渲染更加高效,因为它避免了多余的绘制,但我们的过程绘制命令只是一个接一个地渲染点。然而,减少的CPU工作和数据传输,加上GPU以全速渲染所有立方体的能力,足以弥补这一点。
2.2 检索位置
为了检索我们存储在GPU上的点位置,我们必须首先为BRP创建一个新的着色器。复制点Point Surface着色器并将其重命名为Point Surface GPU。调整它的着色器菜单标签以匹配。此外,我们现在依赖于一个由计算着色器填充的结构化缓冲区,将着色器的目标级别提高到4.5。这不是严格需要的,但表明我们需要计算着色器支持。
Shader "Graph/Point Surface GPU" {
Properties {
_Smoothness ("Smoothness", Range(0,1)) = 0.5
}
SubShader {
CGPROGRAM
#pragma surface ConfigureSurface Standard fullforwardshadows
#pragma target 4.5
…
ENDCG
}
FallBack "Diffuse"
}
目标级别4.5意味着什么? 这表明我们至少需要OpenGL ES 3.1的功能。它不适用于旧的dx11前gpu,也不适用于OpenGL ES 2.0或3.0。这也排除了WebGL。WebGL 2.0有一些实验性的计算着色器支持,但Unity目前还不支持。 在支持不足的情况下运行GPU图形最多只能导致所有点重叠,就像现在所发生的那样。所以如果你的目标平台是那样的化,你就必须坚持旧的方法,或者同时包含这两种方法,并退回到低分辨率的CPU图形中
程序化渲染像GPU实例化一样工作,但是我们需要指定一个额外的选项,通过添加#pragma instancing_options指令来表示。在这种情况下,我们必须使用procedural:ConfigureProcedural选项。
#pragma surface ConfigureSurface Standard fullforwardshadows
#pragma instancing_options procedural:ConfigureProcedural
这表明表面着色器需要调用每个顶点的ConfigureProcedural函数。它是一个没有任何参数的void函数。把它添加到我们的着色器中。
void ConfigureProcedural () {
}
void ConfigureSurface (Input input, inout SurfaceOutputStandard surface) {
surface.Albedo = saturate(input.worldPos * 0.5 + 0.5);
surface.Smoothness = _Smoothness;
}
默认情况下,这个函数只会被常规的渲染通道调用。为了在渲染阴影时应用它,我们必须通过添加addshadow到#pragma surface指令来表明我们需要一个自定义的阴影通道。
#pragma surface ConfigureSurface Standard fullforwardshadows addshadow
现在添加我们在计算着色器中声明的相同位置缓冲区字段。这次我们只读取它,所以给它一个StructuredBuffer类型,而不是RWStructuredBuffer。
StructuredBuffer<float3> _Positions;
void ConfigureProcedural () {
}
但我们应该只对为程序化绘制的专门编译的shader变量这样做。这是定义UNITY_PROCEDURAL_INSTANCING_ENABLED宏标签时的情况。我们可以通过写入#if defined(UNITY_PROCEDURAL_INSTANCING_ENABLED)来检查。这是一个预处理器指令,它指示编译器只在定义了标签的情况下包含下列行中的代码。这适用于只包含#endif指令的行之前。它的工作原理类似于C#中的条件块,除了代码在编译过程中被包含或省略。最终代码中不存在分支。
#if defined(UNITY_PROCEDURAL_INSTANCING_ENABLED)
StructuredBuffer<float3> _Positions;
#endif
对于将要放入ConfigureProcedural函数中的代码,我们必须做同样的操作。
void ConfigureProcedural () {
#if defined(UNITY_PROCEDURAL_INSTANCING_ENABLED)
#endif
}
现在,我们可以通过使用当前正在绘制的实例的标识符索引位置缓冲区来检索点的位置。我们可以通过unity_InstanceID访问它的标识符,这是全局可访问的。
void ConfigureProcedural () {
#if defined(UNITY_PROCEDURAL_INSTANCING_ENABLED)
float3 position = _Positions[unity_InstanceID];
#endif
}
2.3 创建转换矩阵
一旦我们有了位置,下一步就是为这个点创建一个对象到世界的转换矩阵。为了使事情尽可能简单,我们将图像固定在世界原点,没有任何旋转或缩放。调整GPU Graph对象的Transform组件不会有任何效果,因为我们没有使用它做任何事情。
我们只需要应用点的位置和比例。位置存储在4×4变换矩阵的最后一列,而比例存储