原文地址:https://catlikecoding.com/unity/tutorials/basics/compute-shaders/
在计算缓冲区中存储位置。
让GPU来做大部分的工作。
循序渐进地绘制多个立方体。
将整个函数库复制到GPU上。
这是关于学习使用Unity的basics系列的第五篇教程。这一次,我们将使用计算着色器来显著增加我们的图形的分辨率。
本教程使用Unity 2020.3.6f1制作。
我们的图像分辨率越高,CPU和GPU需要做的工作就越多,比如计算位置和渲染立方体。点的数量等于分辨率的平方,因此将分辨率翻倍会显著增加工作量。我们也许能够在分辨率为100的情况下达到60FPS,但我们能做到什么程度呢?如果我们遇到瓶颈,我们能通过使用不同的方法来突破它吗?
让我们先将Graph的最大分辨率从100提高一倍到200,看看我们获得了怎样的性能。
[SerializeField, Range(10, 200)]
int resolution = 10;
我们现在要渲染40000个点。在我的例子中,BRP构建的平均帧率降至10FPS, URP构建的平均帧率降至15FPS。这对于平稳的体验来说太低了。
分析一个构建可以发现,所有的事情都要花费大约四倍的时间,这是有道理的。
排序、批处理,然后将40000个点的转换矩阵发送给GPU需要大量的时间。一个矩阵由16个浮点数组成,每个浮点数4个字节,每个矩阵总共64B。对于40000个点来说,每次绘制点时都需要将256万字节(约为2.44 mb)复制到GPU上。URP每帧需要做两次,一次是阴影,一次是常规几何。BRP至少要这样做三次,因为它有额外的仅限深度的通道,而且除了主方向的光外,每个光都要再通过一次。
MiB是什么? 因为计算机硬件使用二进制数来寻址内存,所以它是以2的幂而不是10的幂来划分的。MiB是mebibyte的后缀,即2 ^ 20 = 1,024 ^2 = 1,048,576字节。这最初被称为兆字节(以mb表示),但现在应该表示10 ^ 6字节,符合官方定义的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版本保持激活。
为了在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;
}
如果我们不显式地释放缓冲区会发生什么? 当垃圾回收器回收该对象时,如果没有任何东西持有该对象的引用,则该对象最终将被释放。但这种情况的发生是任意的。最好尽快显式地释放它,以避免内存阻塞。
为了计算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 () {}
当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) {}
如果我们知道图的步长,我们可以将线程标识符转换为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;
}
为了存储一个位置,我们需要访问位置缓冲区。在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;
}
}
我们现在可以在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));
}
我们有一个计算和存储图中点位置的核函数。下一步是在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();
}
现在我们正在计算游戏模式下每一帧的所有图形位置,尽管我们并没有注意到这一点,也没有对数据做任何操作。
有了GPU上可用的位置,下一步是绘制点,不需要从CPU发送任何转换矩阵到GPU。因此,着色器将不得不从缓冲区中检索正确的位置,而不是依赖于标准矩阵。
因为这些位置已经存在于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以全速渲染所有立方体的能力,足以弥补这一点。
为了检索我们存储在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
}
一旦我们有了位置,下一步就是为这个点创建一个对象到世界的转换矩阵。为了使事情尽可能简单,我们将图像固定在世界原点,没有任何旋转或缩放。调整GPU Graph对象的Transform组件不会有任何效果,因为我们没有使用它做任何事情。
我们只需要应用点的位置和比例。位置存储在4×4变换矩阵的最后一列,而比例存储在矩阵对角线上。矩阵的最后一个分量总是设为1。所有其他分量对我们来说都是零。
变换矩阵用于将顶点从对象空间转换到世界空间。它是通过unity_ObjectToWorld全局提供的。因为我们是根据程序绘制它是一个单位矩阵,所以我们要替换它。最初将整个矩阵设为0。
float3 position = _Positions[unity_InstanceID];
unity_ObjectToWorld = 0.0;
我们可以通过float4(position, 1.0)构造一个位置偏移的列向量。我们可以通过将其分配给unity_ObjectToWorld._m03_m13_m23_m33来将其设置为第四列。
unity_ObjectToWorld = 0.0;
unity_ObjectToWorld._m03_m13_m23_m33 = float4(position, 1.0);
然后添加一个float _Step着色器属性到我们的着色器,并将其分配给unity_ObjectToWorld._m00_m11_m22。这正确地缩放了我们的点。
float _Step;
void ConfigureProcedural () {
#if defined(UNITY_PROCEDURAL_INSTANCING_ENABLED)
float3 position = _Positions[unity_InstanceID];
unity_ObjectToWorld = 0.0;
unity_ObjectToWorld._m03_m13_m23_m33 = float4(position, 1.0);
unity_ObjectToWorld._m00_m11_m22 = _Step;
#endif
}
还有一个unity_WorldToObject矩阵,它包含用于转换法向量的逆变换。当应用非均匀变形时,需要正确地变换方向矢量。但由于它不适用于我们的图,我们可以忽略它。我们应该告诉我们的着色器,通过添加assumeuniformscaling到实例化选项pragma。
#pragma instancing_options assumeuniformscaling procedural:ConfigureProcedural
现在创建一个使用这个着色器的新材质,启用GPU实例化,并将它分配给我们的GPU graph。
为了使这个工作正确,我们必须设置材质的属性,就像我们之前设置计算着色器一样。在绘制前在UpdateFunctionOnGPU中调用SetBuffer和SetFloat函数。在这种情况下,我们不需要为缓冲区提供内核索引。
material.SetBuffer(positionsId, positionsBuffer);
material.SetFloat(stepId, step);
var bounds = new Bounds(Vector3.zero, Vector3.one * (2f + 2f / resolution));
Graphics.DrawMeshInstancedProcedural(
mesh, 0, material, bounds, positionsBuffer.count
);
当我们进入游戏模式时,我们再次看到我们的图表,但现在它的40000个点以稳定的60FPS呈现。如果我在编辑器游戏窗口中关闭垂直同步,它的帧率将达到245FPS。我们的程序方法显然比每个点使用一个游戏对象要快得多。
分析构建可以发现我们的GPUGraph组件几乎没有任何事情要做。它只指示GPU运行一个计算着色内核,然后告诉Unity程序地绘制许多点。这不会立即发生。计算着色器被调度并将在GPU空闲时运行。程序绘制命令稍后由BRP发送给GPU。该命令被发送三次,一次用于深度通道,一次用于阴影,一次用于最终绘制。GPU将首先运行计算着色器,只有当它完成时才可以绘制场景,之后它可以运行计算着色器的下一次调用。Unity对于4万个点能够轻松做到。
因为它可以很好地处理40000个点,让我们看看我们的GPU图形是否可以处理100万个点。但在此之前,我们必须了解异步着色器编译。这是Unity编辑器的一个特性,而不是构建。编辑器只在需要的时候才编译着色器,而不是提前编译。当编辑着色器时,这可以节省大量的编译时间,但意味着着色器并不总是立即可用。当这种情况发生时,一个统一的青色虚拟着色器被临时使用,直到着色器编译过程完成,这个过程时并行运行。这通常是好的,但虚拟着色器不在程序绘图中工作。这将大大减慢绘制过程。如果在尝试渲染100万个点数时发生这种情况,它很可能会冻结并导致Unity崩溃,甚至可能导致你的整个机器崩溃。
我们可以通过项目设置关闭异步着色器编译,但这对于Point Surface GPU着色器会是一个问题。幸运的是,我们可以通过添加#pragma editor_sync_compilation指令来告诉Unity对一个特定的着色器使用同步编译。这将迫使Unity在第一次使用着色器之前暂停并立即编译它,避免使用虚拟着色器。
#pragma surface ConfigureSurface Standard fullforwardshadows addshadow
#pragma instancing_options assumeuniformscaling procedural:ConfigureProcedural
#pragma editor_sync_compilation
#pragma target 4.5
现在可以安全地将GPUGraph的分辨率限制提高到1000。
[SerializeField, Range(10, 1000)]
int resolution = 10;
让我们试试最大分辨率。
当在一个小窗口观看时,它看起来不太漂亮——会出现摩尔纹,因为点是如此之小,但它能运行。对我来说,100万个动画点都是以24FPS渲染的。在编辑器和构建中性能是相同的。在这一点上,编辑器开销是微不足道的,GPU是瓶颈。此外,垂直同步是否启用在我的情况下没有明显的不同。
当垂直同步被禁用时,很明显玩家循环的大部分时间都花在等待GPU完成。GPU确实是瓶颈。我们可以在不影响性能的情况下向CPU添加相当多的工作负载。
注意,我们渲染了100万个带有阴影的点,这要求它们在BRP中每帧绘制三次。在没有垂直同步的情况下,禁用阴影可以让我的平均帧率提高到65FPS左右。
当然,如果你发现帧率不足,你不需要把分辨率一直提高到1000。将它降低到700可能已经使它在启用阴影的情况下以60FPS的速度运行,并且看起来基本相同。但从现在开始,我会一直使用1000分辨率。
为了了解URP是如何执行的,我们还需要复制我们的Point URP着色器图,将其重命名为Point URP GPU。Shader graph不直接支持过程绘制,但是我们可以用一些自定义代码让它工作。为了使此过程变得简单并重用一些代码,我们将创建一个HLSL包含文件资产。Unity没有这个菜单选项,所以只需复制一个表面着色器资产,并将其重命名为PointGPU。然后使用系统的文件浏览器将资产的文件扩展名从shader更改为hlsl。
清除文件的内容,然后从Points Surface GPU复制位置缓冲区、缩放和ConfigureProcedural函数的代码到该文件。
#if defined(UNITY_PROCEDURAL_INSTANCING_ENABLED)
StructuredBuffer<float3> _Positions;
#endif
float _Step;
void ConfigureProcedural () {
#if defined(UNITY_PROCEDURAL_INSTANCING_ENABLED)
float3 position = _Positions[unity_InstanceID];
unity_ObjectToWorld = 0.0;
unity_ObjectToWorld._m03_m13_m23_m33 = float4(position, 1.0);
unity_ObjectToWorld._m00_m11_m22 = _Step;
#endif
}
我们现在可以通过#include "PointGPU.hlsl"将该文件包含在Point Surface GPU着色器中。指令,在此之后,原始代码可以从其中删除。
#include "PointGPU.hlsl"
struct Input {
float3 worldPos;
};
float _Smoothness;
//#if defined(UNITY_PROCEDURAL_INSTANCING_ENABLED)
// StructuredBuffer _Positions;
//#endif
//float _Step;
//void ConfigureProcedural () { … }
void ConfigureSurface (Input input, inout SurfaceOutputStandard surface) { … }
我们可以在CGPROGRAM着色器中包含一个HLSL文件吗? 是的。CGPROGRAM块和HLSLPROGRAM块之间的唯一区别是,前者默认包含一些文件。这种差异与我们无关。
我们将使用一个Custom Function节点来在着色器图中包含HLSL文件。其思想是节点从文件中调用一个函数。虽然我们不需要这个功能,但是除非我们将代码连接到我们的图,否则不会包含代码。因此,我们将向PointGPU添加一个正确格式化的虚拟函数,它只传递一个float3值,而不改变它。
添加一个void ShaderGraphFunction_float函数到PointGPU,使用两个float3参数命名为In和Out。该函数只是将输入分配给输出。参数名称按惯例大写,因为它们将对应着色器图中使用的输入和输出标签。
void ShaderGraphFunction_float (float3 In, float3 Out) {
Out = In;
}
这假设Out参数是一个输出参数,我们必须通过在输出参数前面写出来来声明它。
void ShaderGraphFunction_float (float3 In, out float3 Out) {
Out = In;
}
函数名的_float后缀是必需的,因为它表示函数的精度。着色器图形提供了两种精确模式,float或half。后者的大小是前者的一半,因此是两个字节而不是四个字节。节点使用的精度可以显式选择或设置为继承,这是默认值。为了确保我们的图在两种精度模式下都能工作,还需要添加一个使用半精度的变量函数。
void ShaderGraphFunction_float (float3 In, out float3 Out) {
Out = In;
}
void ShaderGraphFunction_half (half3 In, out half3 Out) {
Out = In;
}
现在添加一个自定义函数节点到我们的Point URP GPU图形。它的Type默认设置为File。将PointGPU赋值给它的Source属性。使用ShaderGraphFunction作为它的Name,不带精度后缀。然后将In添加到Inputs列表中,并将Out添加到Outputs列表中,两者都作为Vector3。
为了将代码集成到图中,我们必须将节点连接到图中。当顶点阶段需要它时,将它的输出连接到Vertex节点的Position。然后添加一个Position节点到对象空间,并将其连接到我们的自定义节点的输入。
现在物体空间顶点位置通过我们的虚拟函数传递,我们的代码被包含在生成的着色器中。但是要启用过程呈现,我们还必须包含#pragma instancing_options和#pragma editor_sync_compile编译器指令。这些必须直接注入到生成的着色器源代码中,它们不能通过一个单独的文件包含。因此,添加另一个Custom Function节点,它的输入和输出与前面相同,但这次的Type设置为String。将其名称设置为适当的名称(比如Injectpragmas),然后将这些指令放在Body文本块中。函数体充当函数的代码块,因此我们还必须在这里将输入赋值给输出。
为了清晰起见,下面是函数体代码:
#pragma instancing_options assumeuniformscaling procedural:ConfigureProcedural
#pragma editor_sync_compilation
Out = In;
通过这个节点也传递顶点位置,在其他自定义函数节点之前或之后。
使用Point URP GPU着色器创建一个启用实例的材质,将其分配给我们的图形,然后进入播放模式。我现在在编辑器和构建中都获得了36FPS,并启用了阴影。比BRP快50%。
垂直同步对平均帧率没有影响。禁用阴影将其提高到69FPS,这与BRP大致相同,只是玩家循环需要更少的时间。
因为我们目前总是为缓冲区中的每个位置绘制一个点,在播放模式下降低分辨率将固定一些点。这是因为计算着色器只更新已经存在图中的点。
无法调整计算缓冲区的大小。我们可以在每次分辨率改变时创建一个新的,但另一种更简单的方法是总是为最大分辨率分配一个缓冲区。这将使得在游戏模式下轻松地改变分辨率。
首先将最大分辨率定义为一个常量,然后在分辨率字段的Range属性中使用它。
const int maxResolution = 1000;
…
[SerializeField, Range(10, maxResolution)]
int resolution = 10;
接下来,总是使用最大分辨率的平方来表示缓冲区的元素数量。这意味着无论图形分辨率如何,我们总是需要12MB(大约11.44 MiB)的GPU内存。
void OnEnable () {
positionsBuffer = new ComputeBuffer(maxResolution * maxResolution, 3 * 4);
}
最后,绘制时使用当前分辨率的平方而不是缓冲区元素的个数。
void UpdateFunctionOnGPU () {
…
Graphics.DrawMeshInstancedProcedural(
mesh, 0, material, bounds, resolution * resolution
);
}
现在我们基于gpu的方法完全是函数的,让我们把我们的整个函数库移植到我们的计算着色器。
我们可以复制其他函数,就像我们复制和调整Wave一样。第二个是MultiWave。与Wave唯一显著的区别是它包含float。在HLSL中不存在f后缀,所以应该从所有数字中删除。为了表明它们都是浮点值,我显式地为它们添加了一个点,例如2f变成了2.0。
float3 MultiWave (float u, float v, float t) {
float3 p;
p.x = u;
p.y = sin(PI * (u + 0.5 * t));
p.y += 0.5 * sin(2.0 * PI * (v + t));
p.y += sin(PI * (u + v + 0.25 * t));
p.y *= 1.0 / 2.5;
p.z = v;
return p;
}
对其余函数执行相同的操作。Sqrt变成sqrt,Cos变成Cos。
float3 Ripple (float u, float v, float t) {
float d = sqrt(u * u + v * v);
float3 p;
p.x = u;
p.y = sin(PI * (4.0 * d - t));
p.y /= 1.0 + 10.0 * d;
p.z = v;
return p;
}
float3 Sphere (float u, float v, float t) {
float r = 0.9 + 0.1 * sin(PI * (6.0 * u + 4.0 * v + t));
float s = r * cos(0.5 * PI * v);
float3 p;
p.x = s * sin(PI * u);
p.y = r * sin(0.5 * PI * v);
p.z = s * cos(PI * u);
return p;
}
float3 Torus (float u, float v, float t) {
float r1 = 0.7 + 0.1 * sin(PI * (6.0 * u + 0.5 * t));
float r2 = 0.15 + 0.05 * sin(PI * (8.0 * u + 4.0 * v + 2.0 * t));
float s = r2 * cos(PI * v) + r1;
float3 p;
p.x = s * sin(PI * u);
p.y = r2 * sin(PI * v);
p.z = s * cos(PI * u);
return p;
}
现在我们必须为每个图形函数创建一个单独的内核函数,但这是大量重复的代码。我们可以通过创建着色器宏来避免这种情况,就像我们之前定义的PI一样。首先在FunctionKernel函数的上面一行写#define KERNEL_FUNCTION。
#define KERNEL_FUNCTION
[numthreads(8, 8, 1)]
void FunctionKernel (uint3 id: SV_DispatchThreadID) { … }
这些定义通常只适用于它们后面同一行的任何内容,但我们可以通过在除最后一行以外的每一行末尾添加一个\反斜杠将其扩展为多行。
#define KERNEL_FUNCTION \
[numthreads(8, 8, 1)] \
void FunctionKernel (uint3 id: SV_DispatchThreadID) { \
float2 uv = GetUV(id); \
SetPosition(id, Wave(uv.x, uv.y, _Time)); \
}
现在,当我们编写KERNEL_FUNCTION时,编译器将用FunctionKernel函数的代码替换它。为了使它适用于任意函数,我们向宏添加一个参数。这类似于函数的形参列表,但是没有类型,并且必须将左括号附加到宏名称上。给它一个单独的function参数,然后使用它来代替Wave的显式调用。
#define KERNEL_FUNCTION(function) \
[numthreads(8, 8, 1)] \
void FunctionKernel (uint3 id: SV_DispatchThreadID) { \
float2 uv = GetUV(id); \
SetPosition(id, function(uv.x, uv.y, _Time)); \
}
我们还必须改变核函数的名称。我们将使用function参数作为前缀,后面是Kernel。我们必须保持 function标签分开,否则它不会被识别为着色器参数。要组合两个单词,请使用##宏连接操作符连接它们。
void function##Kernel (uint3 id: SV_DispatchThreadID) { \
现在可以通过使用适当的参数编写KERNEL_FUNCTION来定义所有五个内核函数。
#define KERNEL_FUNCTION(function) \
…
KERNEL_FUNCTION(Wave)
KERNEL_FUNCTION(MultiWave)
KERNEL_FUNCTION(Ripple)
KERNEL_FUNCTION(Sphere)
KERNEL_FUNCTION(Torus)
我们还必须为每个函数替换一个单独的内核指令,顺序与FunctionLibrary.FunctionName匹配。
最后一步是使用当前函数作为GPUGraph.UpdateFunctionOnGPU中的内核索引,而不是总是使用零。
var kernelIndex = (int)function;
computeShader.SetBuffer(kernelIndex, positionsId, positionsBuffer);
int groups = Mathf.CeilToInt(resolution / 8f);
computeShader.Dispatch(kernelIndex, groups, groups, 1);
计算着色器运行得如此之快,以至于显示哪个函数并不重要,它们的帧率都是相同的。
支持从一个函数转换到另一个函数有点复杂,因为我们需要一个单独的内核来处理每个唯一的转换。开始添加一个属性为过渡进度计算着色器,我们将使用它来混合函数。
float _Step, _Time, _TransitionProgress;
然后复制内核宏,将其重命名为KERNEL_MORPH_FUNCTION,并给它两个参数:functionA和functionB。将函数的名称改为functionA##To##functionB##Kernel,并使用lerp在基于进度计算的位置之间进行线性插值。我们也可以在这里使用smoothstep,但是我们只会在CPU上每帧计算一次。
#define KERNEL_MORPH_FUNCTION(functionA, functionB) \
[numthreads(8, 8, 1)] \
void functionA##To##functionB##Kernel (uint3 id: SV_DispatchThreadID) { \
float2 uv = GetUV(id); \
float3 position = lerp( \
functionA(uv.x, uv.y, _Time), functionB(uv.x, uv.y, _Time), \
_TransitionProgress \
); \
SetPosition(id, position); \
}
每个函数都可以过渡到其他函数,所以每个函数有四个过渡。为所有这些添加内核函数。
KERNEL_FUNCTION(Wave)
KERNEL_FUNCTION(MultiWave)
KERNEL_FUNCTION(Ripple)
KERNEL_FUNCTION(Sphere)
KERNEL_FUNCTION(Torus)
KERNEL_MORPH_FUNCTION(Wave, MultiWave);
KERNEL_MORPH_FUNCTION(Wave, Ripple);
KERNEL_MORPH_FUNCTION(Wave, Sphere);
KERNEL_MORPH_FUNCTION(Wave, Torus);
KERNEL_MORPH_FUNCTION(MultiWave, Wave);
KERNEL_MORPH_FUNCTION(MultiWave, Ripple);
KERNEL_MORPH_FUNCTION(MultiWave, Sphere);
KERNEL_MORPH_FUNCTION(MultiWave, Torus);
KERNEL_MORPH_FUNCTION(Ripple, Wave);
KERNEL_MORPH_FUNCTION(Ripple, MultiWave);
KERNEL_MORPH_FUNCTION(Ripple, Sphere);
KERNEL_MORPH_FUNCTION(Ripple, Torus);
KERNEL_MORPH_FUNCTION(Sphere, Wave);
KERNEL_MORPH_FUNCTION(Sphere, MultiWave);
KERNEL_MORPH_FUNCTION(Sphere, Ripple);
KERNEL_MORPH_FUNCTION(Sphere, Torus);
KERNEL_MORPH_FUNCTION(Torus, Wave);
KERNEL_MORPH_FUNCTION(Torus, MultiWave);
KERNEL_MORPH_FUNCTION(Torus, Ripple);
KERNEL_MORPH_FUNCTION(Torus, Sphere);
我们将定义内核,使它们的索引等于functionB + functionA * 5,将不进行转换的内核视为从同一个函数转换到同一个函数。因此,第一个内核是Wave,接下来是从Wave到其他函数的四个内核。之后是从MultiWave开始的函数,第二个是非过渡内核,依此类推。
#pragma kernel WaveKernel
#pragma kernel WaveToMultiWaveKernel
#pragma kernel WaveToRippleKernel
#pragma kernel WaveToSphereKernel
#pragma kernel WaveToTorusKernel
#pragma kernel MultiWaveToWaveKernel
#pragma kernel MultiWaveKernel
#pragma kernel MultiWaveToRippleKernel
#pragma kernel MultiWaveToSphereKernel
#pragma kernel MultiWaveToTorusKernel
#pragma kernel RippleToWaveKernel
#pragma kernel RippleToMultiWaveKernel
#pragma kernel RippleKernel
#pragma kernel RippleToSphereKernel
#pragma kernel RippleToTorusKernel
#pragma kernel SphereToWaveKernel
#pragma kernel SphereToMultiWaveKernel
#pragma kernel SphereToRippleKernel
#pragma kernel SphereKernel
#pragma kernel SphereToTorusKernel
#pragma kernel TorusToWaveKernel
#pragma kernel TorusToMultiWaveKernel
#pragma kernel TorusToRippleKernel
#pragma kernel TorusToSphereKernel
#pragma kernel TorusKernel
回到GPUGraph,添加转换进度着色器属性的标识符。
static readonly int
…
timeId = Shader.PropertyToID("_Time"),
transitionProgressId = Shader.PropertyToID("_TransitionProgress");
如果我们正在转换,就在UpdateFunctionOnGPU中设置它。在这里我们应用了smoothstep函数,所以我们不需要对GPU上的每个点都这样做。这是一个小的优化,并避免了大量的工作。
要选择正确的内核索引,可以向它添加5倍的转换函数,如果没有进行转换,则可以添加相同的5倍函数。
var kernelIndex =
(int)function + (int)(transitioning ? transitionFunction : function) * 5;
添加的转换仍然不会影响我的帧率。很明显,瓶颈是渲染,而不是位置的计算。
要计算内核索引,GPUGraph需要知道有多少函数。我们可以在FunctionLibrary中添加GetFunctionCount方法来返回它,而不是在GPUGraph中硬编码。这样做的好处是,如果我们要添加或删除一个函数,我们只需要更改两个FunctionLibrary文件——类和计算着色器。
public static int GetFunctionCount () {
return 5;
}
我们甚至可以删除常量值并返回functions数组的长度,从而进一步减少以后需要更改的代码。
public static int GetFunctionCount () {
return functions.Length;
}
函数计数是一个很好的候选对象,可以转换为属性。要自己创建一个函数,请从GetFunctionCount中删除Get前缀,并删除它的空参数列表。然后将返回语句包装在嵌套的get代码块中。
public static int FunctionCount {
get {
return functions.Length;
}
}
这定义了一个getter属性。因为它唯一做的事情是返回一个值,我们可以通过将get块减少为一个表达式体来简化它,这可以通过用get => functions.Length;替换来完成。
public static int FunctionCount {
get => functions.Length;
}
因为没有set块,我们可以通过省略get来进一步简化属性。这将属性简化为一行。
public static int FunctionCount => functions.Length;
这也适用于类似的方法,在本例中是GetFunction和GetNextFunctionName。
public static Function GetFunction (FunctionName name) => functions[(int)name];
public static FunctionName GetNextFunctionName (FunctionName name) =>
(int)name < functions.Length - 1 ? name + 1 : 0;
在GPUGraph.UpdateFunctionOnGPU中使用新属性来代替常量值。
var kernelIndex =
(int)function +
(int)(transitioning ? transitionFunction : function) *
FunctionLibrary.FunctionCount;
总之,由于分辨率的提高,我们的函数可以变得更加详细。例如,我们可以将球体扭转的频率加倍。
float3 Sphere (float u, float v, float t) {
float r = 0.9 + 0.1 * sin(PI * (12.0 * u + 8.0 * v + t));
…
}
星形图和环面扭曲也是如此。这将使扭转看起来相对于主图案移动得更慢,所以也将它们的时间因素放大一点。
float3 Torus (float u, float v, float t) {
float r1 = 0.7 + 0.1 * sin(PI * (8.0 * u + 0.5 * t));
float r2 = 0.15 + 0.05 * sin(PI * (16.0 * u + 8.0 * v + 3.0 * t));
…
}
为了保持两个函数库同步,还需要调整FunctionLibrary类中的函数。这使得基于cpu的游戏对象和基于程序gpu的方法之间的比较更加真实。
下一个教程是Jobs。