原文链接:《UE4:Niagara 中的 UAV》 | 作者:Feilon
Edition 1.00. Base on UE Ver.4.26
第一章:概述
从这篇文章开始我们进入正题,开始分模块、分具体变量类型地讲解一下 Niagara 中的各个部分。
Niagara 中的大部分模块、函数都是用 Niagara 内部的蓝图来写,双击就能看到实现细节,非常方便我们学习。但是因为读蓝图逻辑本身不是一件那么优雅的事情,虽然官方在正式模块中已经尽可能地加了注释,码好节点位置,但也架不住各种 Static Switch 条件全部集成在一个模块里、各种 Flag 判断条件乱飞的复杂情况,我们还是尽可能对每个这样复杂的模块做一些梳理。
其实很多情况我们不知道内部的实现机制,只需要知道其功能就可以了。然而有时候还是会在极端情况下遇到一些问题,明白底层机制能帮我们快速定位问题。即使是很多常用模块,本人在使用时还是遇到了很多奇奇怪怪的需要注意的事项,所以本人认为还是有必要去梳理一下实现原理的。
开篇首当其冲的就是最新的 UAV 的功能,包括 Grid2D 与 Grid3D 的所有节点、HLSL 的写法、使用注意事项与原理。
第二章:UAV 概述
虽然之前讲过,但是我们这里还是再简答重复一遍,究竟什么是 UAV,它又能给我们带来什么。
UAV全名为 Unordered Access View,它的存在源自 GPU 特殊的运行方式。对于程序出身的人来说,直接把这个当成一个固定大小的多维数组即可。对于美术出身的人来说,可以把这个理解为一张可以任意修改的图片。
一般在 CPU 中,一个可以随意读写的多维数组是一个再普通不过的事情,甚至我们的数组大小还可以动态改变,但是 GPU 上,这样可读写的资源是十分宝贵的。
一般来说,GPU 的数据源与结果都是类似图片的二维数据集合,数据源我们申请后需要通过 Sampler 读取,这个读取可以对任意位置进行读操作,但是不能写。目标数据的写的过程一般只能对当前执行单元对应的那个像素位置进行操作,不能做任何的读操作。
任意位置的写权限涉及到并行操作的冲突问题,即使开放给用户,也可能会造成结果错误或影响运行效率等。一个需要可能产生冲突的任意写操作的 GPU 程序,可能设计本身就有问题,不是一个好的 GPU 程序。
但是我们还需要注意的一个点是,GPU 上不存在所谓的动态资源,这意味着一个贴图在当前帧,赋予了任意读的属性,便不能在当前帧的下一个执行阶段来写。赋予了可以写操作的资源,不能在当前帧进行读。我们只能在帧的开始或最后进行数据拷贝,在第二帧才能利用到我们写上去的资源。这种跨帧并且来回拷贝的操作先不说运行效率,肯定对于编写者来说是极其不方便的。
UAV 其实解决的最关键的问题就是这一点。虽然我们在同一个计算阶段随意读写可能会或多或少有些逻辑问题,但是下一个计算阶段我们直接就能利用我们得到的结果,并在每一个计算阶段反复修改,而无需等待跨帧时做拷贝操作来改变资源的读写权限。这样的程序设计是直观的,是简便的。
UE 在之前确实是可以通过声明两个 RenderTarget,每一帧切换一下两个 RT 的读与写的角色,并通过蓝图来完成 RT 间的数据拷贝,实现类似的功能,但是确实非常麻烦。
而 UAV 本身其实也是一个 UE 早就支持了的内容,并且大量用于 ComputeShader中。这个使用麻烦的地方在于,不能仅仅通过 usf 文件来完成,而是必须通过 C++ 去申请 UAV 的资源,对应的还要写很多其他的语句来配合。并且由于 UAV 的数据是不会跨帧保存的,这意味着我们的每一帧的数据源还是需要一个普通的贴图资源。我们下一帧想在前一帧结果的基础上做运算,还是需要手动做贴图的拷贝工作。
粒子系统本身虽然不能做到任意位置的读写,但是是可以做到同一帧的任意时刻对自身进行读写的,并且数据能够跨帧保存,这正好弥补了 UAV 不能跨帧保存的缺点,可以说粒子系统与 UAV 的结合,是天然的。
第三章:Niagara 中的 UAV 概述
Niagara 提供了多种类型的 UAV 供我们使用,UAV 的每一个单元可以存放一些稍微复杂的元素,可以有 2维 跟 3维 的 UAV,并且提供了一个集成简易的空间 Hash 的 3维 UAV 类型。虽然原理都是一样的,但实际使用上,这几种类型各有各的适用范围,这可能是官方出于某些对多平台不同兼容性的考虑,也可能只是单纯的没做好,之后我们会详细解释。
虽然 UAV 为我们的 SimulationStage 提供了一个中间数据结构,使我们可以在同一个 Emitter 中获得其他粒子当前帧修改后的值,但是因为 UAV 毕竟是数组结构,我们如果真的只是想找其他粒子的值,需要自己去做储存的索引结构。
而虽然我们的 UAV 可以做到在同一个 SimulationStage 的蓝图中对任意位置读与写,但是我们在当前执行阶段其实是获取不到写后的内容的。也就是说我们读的内容永远都是当前阶段的初始值,而写会往另一个临时位置写。在当前阶段开始时,代码已经自动帮我们把初始值原封不动拷贝到输出值上,因此我们无须考虑没有被我们的执行逻辑赋值的位置的数据怎么办。而当前阶段结束后代码会自动帮我们把临时输出拷贝回读取位置。
也就是说底层虽然是用 UAV 实现,但是我们其实实现不了在同一个位置写后马上读到当前值,必须等下一个执行阶段才能读到。如果有这样的需求我们直接把计算分成两个 SimulationStage 即可。
这种自动处理是否方便,因为有时候我们想在原位做模糊等操作,直接在原数据位置读写肯定是不对的,毕竟我们无法控制每个像素被读与被写的先后顺序,结果肯定是错误的。所以我们必须把读与写的资源分开,就需要上面这些操作,后台都帮我们做好了就不需要我们考虑这些了。
第四章:Niagara 中的 UAV 使用与 Bug
UAV 可以在 CPU 与非 SimulationStage 下使用,但是这不给我们带来额外的功能,没有什么意义。
UAV 一般还是在 SimulationStage 与 GPU 上使用。对粒子遍历时也可以读写 UAV,但是想遍历 UAV 只能通过 SimulationStage 进行。
我们肯定不能让每个粒子拥有一个 UAV,但是逻辑上 System 是可以拥有 UAV 的,但是我们在 Emitter 的 SimulationStage 中指定遍历对象时,找不到在 System 下的 UAV 变量。
我们先声明一个 Emitter 下的 UAV 变量。之后我们可以拖入 Emitter 的 EmitterSpawn 位置进行初始化设置,然后就可以在 SimulationStage 中找到并指定对其遍历。初始化设置也可以放在 EmitterUpdate 中,即使放在这里也不会每一帧改变 UAV 的大小,因为跟 UAV 相关的参数设置都必须是静态的,不可以与变量关联。而假如我们对同一个 UAV 在整个执行过程中任意位置设置了不同的属性(如大小),虽然编译时不会报错,但是运行时会造成引擎崩溃。
设置 DataInteface 时会出现两个选择,它会把我们再 Set 时产生的唯一命名也索引到,如果 Set 了两次还会出现两个唯一命名,如图:
这几个选项其实没有区别。因为这个遍历选项,本质上并不是真的对某个结构进行遍历,而是只是根据我们传入的内容,决定内部的执行编号范围,这个编号的当前值在蓝图中可以通过 ExecutionIndex 节点拿到,为从 0 到一个最大值的整数。
以上这些都是弱引用,这意味着我们可以对 UAV 变量的名字与命名空间进行修改。然后就会出现下列 Bug 。
首先如果我们修改名称,SimulationStage 中的遍历设定不会随之改变,因为这个设点连引用都不算,所以必须手动更改。而且上述操作只有在我们修改了 Emittter 重新编译时才会报错,非常隐蔽。
而改变命名空间会引发更加神奇的现象。我们知道 System 的变量不能在 Emitter 中改变,所以新建一个 System 的 UAV 不可以在 Emitter 中 Set,也就不能被 SimulationStage 的遍历选项搜到。而由于 Set 是弱引用,我们新建一个 Emitter 的 UAV,然后修改命名空间为 System,就发生了在 Emitter 中设置 System 变量的神奇现象,并且可以被 SimulationStage 检索到。并且这么做也不会有任何运行上的错误。
同理我们甚至能修改命名空间为 Particle ,然后在 Particle 部分去 Set 这个 UAV 的属性,直接创建一个 Particle 命名空间的 UAV ,只要我们 Set 它,也是能被 SimulationStage 找到的。并且也能正常运行
那么实际运行的时候难道给每个 Particle 建立了一个 UAV 吗?当然不是。实际上代码真正申请 GPU 的 UAV 资源并不会根据命名空间的不同进行多份资源的赋值。只要是同命名空间同名的参数在 Emitter 中进行了 Set ,都只会申请 1 个 UAV 资源。并且一定是初始化时申请资源,所以跟执行逻辑的位置也没有关系。
至于修改的限制与 Bug 是因为这个变量遵守了一般变量命名空间的修改限制,并继承了一般变量修改命名空间的 Bug 。一般变量也可以通过上述步骤实现非法命名空间的设置操作,并且修改后编译不报错,链接 Dynamic Input Script 时才会检测到错误并报错。
当然上述说的只是遍历选项能否找到的问题,实际上不管命名空间设置成了什么,都能在蓝图里添加到 ParameterGet 中进行任意位置的读写操作。
弱引用出现的这些问题之所以不编译报错,主要还是因为弱引用并没有对最终生成的代码产生改变,由于他们没有对结果产生共享因此没有生成实际的代码,而一旦需要生成实际代码就会被编译器抓到错误。
不管怎样,我们应该避免这些不规范的操作,UAV 的资源都放在 Emitter 命名空间下,并在 EmitterSpawn 中初始化它们。
最后我们提一个十分十分关键的注意事项,即 UE4 对每个 NiagaraSystem 中所使用的 UAV 数量进行了限制,我们下文提到的每一个变量类型(除了Reader)都算作一个 UAV ,我们创建并在蓝图中引用的 UAV 变量数量不可以超过 8 个,否则直接会触发编译过程中的引擎崩溃,并且这个崩溃是毁灭式的。
一旦我们超过了这个限制(需要注意引擎没有任何提示),并点击了编译或保存,会直接触发整个引擎的崩溃。如果我们重新打开引擎,这个没有被成功编译的粒子系统会以某种条件自动开始编译,继续触发崩溃,于是恶性循环。
如果我们不想删除我们费心编辑的粒子系统,有一个不稳定的解决办法:首先我们要在编译到 UAV 之前打开粒子系统。打开粒子系统后编译的优先级会被提高,留给我们的时间就不多了。一般可能想到的做法是把多出来的 UAV 删掉,然后快速点击编译,不幸的是这个方法并不管用,依旧会产生崩溃并且修改不会被保存,修改无效。正确的做法是进行一些奇怪的修改,让编译会产生其他错误,此时再点击保存,虽然还是会崩溃,但是下次打开时,错误的修改会被保存下来,导致编译过程先报了其他普通错误,不会触发 UAV 的崩溃问题,此时就能拯救我们的文件了。以上过程要赶在自然崩溃前快速完成。
请官方尽快修复这个恶性 Bug 吧。
第五章:Execution Index
我们先介绍一个基本的节点,Execution Index,它在蓝图中是这个:
这个节点非常重要,使用非常频繁,因为它是在我们不使用 AttributeReader 的前提下,少有的几个能让一个粒子与其他粒子建立联系的桥梁。
一般来说 Particle 命名空间有一个 ID 的属性,会自动对每个粒子进行一个唯一的标记,并且也是从 0 开始的,但是这个属性会跟随粒子一生,粒子消失后中间编号会存在空缺。且如果想对粒子系统进行复用,这个编号会持续增长。
而 ExecutionIndex 则是只对 GPU 的每个执行单元编号。每一帧我们需要处理多少个粒子, ExecutionIndex 就会从 0 到这个数减 1 为止。每一帧同一个粒子可能会获得不同的编号,但在同一帧下同一个粒子的 ExecutionIndex 是固定的。
一般 ParticleUpdate 与 SimulationStage 对 Particle 遍历时是统一的,而ParticleSpawn 模块则是要看当前帧有多少粒子被 Spawn ,决定 ExecutionIndex 从 0 到多少,注意这里不会遍历所有存活到的粒子数量。其他位置使用一般都是 0 ,因为都是每帧最多执行一次。
有了这两个线性的编号,我们就能给粒子一个跟编号相关的属性,从而间接地建立起了粒子与粒子之间的联系。
这个编号在 SimulationStage 中对中间数据结构遍历时也同样适用。此时ExecutionIndex 也是一个线性增长的整形数据,因为我们知道不管是二维还是三维数据都是线性储存容器,都跟一维数据等价。
SimulationStage 的遍历可以选择 UAV 或是其他诸如 RenderTarget 等图片类型的数据,而正如我们所说这些都不是真正的遍历,而只是根据输入的数据结构的大小确定了一个 ExecutionIndex 的取值范围,再执行 ExecutionIndex 次数的代码段。由于我们不能通过 Particle 命名空间索引到某个粒子的属性,ExecutionIndex 便成了我们唯一能区别每一次执行的重要依据。
举个例子,假如我们对一个 3维 UAV 遍历,大小是 3*4*5 【60】,那么我们的 ExecutionIndex 就是 0 到 59 之间取值,而通过对 3、4、5 这几个数做除法或取余数,我们就可以获得我们想要的当前像素位置,这往往也是我们最在意的内容。
第六章:Grid2D Collection
Grid2D 是我们最常用的一种 UAV,有两个维度,相当于一个图片。它比图片强大的地方在于,每个像素可以储存任意数据结构类型。把 Grid2D 变量拖到 EmitterSpawn 中,我们就能对其属性进行初始化。
Grid 栏目中可以设置 UAV 的分辨率与 Attribute 的数量。分辨率没什么可说的,关键是 Num Attributes 的含义。我们知道 UAV 的每个像素是能够储存各种数据类型的组合的,这些数据类型会随着我们使用的过程中直接添加,并根据 FName 来分辨与索引。而其实即使我们不追加额外名称的 Attribute,Grid2D 本身也自带一组值,每一个值都默认是一个 float 类型,具体有几个 float 则根据 Num Attributes 来决定。本人一开始以为这个数决定了我们能添加多少不同名称的自定义 Attribute,后来发现根本没关系。
因为默认的 Attribute 不能根据我们的需求区分 Vector 还是 Vector4,所以用起来还是不方便,一般这里设 0 就行。
上面 Grid2DCollection 也有几个重要的变量。第一个是可以把 Grid2D 链接到某个外部 RenderTarget 上的变量,这样我们可以直接在外面实时预览 Grid2D 的值。具体做法是创建一个 User Exposed 命名空间的 Texture Render Target 变量(注意不是 Render Target 2D),就能在 Render Target UserParemeter 中索引到这个变量。之后在 User Parameters 里指定这个变量对应的外部资源,即可同步在 RT 资源中显示。
下图是在 RT 中的预览情况,可以看到 Grid2D 是如何把每个像素的多个数据压到一起的:
这个 Grid2D 我给了两个默认的 Grid Attribute,在另一个脚本中分别设置了一个 Vector2 类型与 Vector4 类型的 Attribute,这样一共 8 个 Float 变量,而这张图则是 X 轴被放大了 8 倍,说明同一个像素的值是横向排布的。而我将Vector2 类型的值都设为了 1 ,观察图片中红色的位置,可以发现每个 Float 会单独存成一个 128*128 的图像,再把这些图像横向排列得到最终的 RT 。这就是Grid2D 的内部结构。
Override Buffer Format 决定了我们每个 Float 用 4 位、2 位还是 1 位字节来储存,我的电脑默认是 2 位字节的,即 16 个 Bit。这个精度其实非常的低,最多只有 4 位有效数字,但是能节省很多空间。由于 Grid2D 只能存 Float,所以使用时需要格外注意精度不够可能带来的问题。比如如果就是想用 Grid2D 存整数,最大只能存到 2000 多就会出现精度不够数据错误的现象。
Preview Attribute 则是能在另一个 Debug 的位置来预览 Grid2D 的值,需要选择指定的 Attribute 名称,默认的 Grid Attribute 名称为 None 。这个本人觉得没有使用 RT 来的方便。
Grid2D 有三种索引方式,一种是线性索引,用一个从 0 开始的整形数据来指定位置,UE 中称这个值为 Linear ,一般我们会把 ExecutionIndex 当做 Linear 来找到对应的坐标。第二种是坐标索引,指定每个维度的从 0 开始的整型坐标值,UE 中称这个值为 Index。第三种是 UV 索引,指定每个维度的从 0 到 1 的 Float 值,UE 中称这个值为 Unit 。三种方式都能通过简单的计算得到,而UE 中也帮我们省去了自己写这些计算的过程,提供了现成的节点:
由于整型到 Float 有一个精度取值的问题,默认情况下整型到 Float 的转换会把整型数据加上 0.5,即取像素点的中心值,反之减 0.5 。上面节点中的 Staggered 意味着不加上这0.5,取边界起始值。
由于我们的 Grid2D 上的值可能输出到 RT 上作为一个世界中存在的平面来展示,因此我们可能会需要世界坐标与对应的 Grid2D 坐标直接的转换。由于这种转换关系会随着我们如何展示 Grid2D 变化,因此 UE 并没有帮我们集成这两种变换,而只是提供了两个徒有其表的节点:
这两个节点都只是用 Unit 或 Simulation 的值左乘输入的矩阵而已,根本不需要 Grid2D 作为输入,我们自己写个矩阵乘法也是完全 OK 的。同时这里的 Unit 的 Z 值没有意义,毕竟是 2D 的数据。
UE4 在 Niagara SimulationStages 插件中给出了计算这个矩阵的模块,名称为Grid2DCreateUnittoWorldTransform,包括能够计算始终面对摄像机的情况下的矩阵。给定任意的世界坐标,这个矩阵能够将这个坐标垂直投射到我们的 Grid2D 平面上。超出 01 的越界问题则需要我们自己处理。
这个模块目前还不是官方内置模块,将来也希望能将这个模块里面的计算集成到上面的两个节点中。
之后是一些固有属性的获取与设置:
GetNumCells 是获取 Grid2D 的维度的,相对重要一些,WorldBBoxSize 的设置以及被废弃,而 CellSize 是二者的商,这两个节点没什么意义。
需要注意的是 SetNumCells 这个节点,因为我们已经说过 UAV 的大小是不能在运行时改变的,但是由于维度的设置不能与变量关联,即使我们想设置一些不会变更的变量来对某些参数做统一修改也不行,一旦想要修改分辨率会非常麻烦,UAV 很多的时候要改很多东西。SetNumCells 就是这个问题的解决办法的折中方案,让我们在蓝图中能输入分辨率的变量,当然这个修改也必须保证 UAV 整个过程分辨率不变,否则会视为修改失败。
因为要多加一个 Module 设置分辨率,所以也还是不太方便。官方不如直接推出一个常变量解决这个问题。还能同时解决很多不能动态修改但想关联配置的设置。
然后就是 Grid2D 的读写操作节点,这些节点的数量非常庞大,但是意义其实非常直观。大体上分为两类,一类是通过给定 Attribute 的名称来设定从 float 到 float4 的属性值,这个名称我们可以自己随意起,然后系统自动识别我们一共使用过多少不同的 Attribute 名称,来自动决定 Grid2D 有多大。另一类是通过严格的某个Attribute 编号来设定,即 Get 或 SetGridValue ,一般我们用它来设定我们之前提到的默认的 Grid2D 的 Attribute ,当然有名称的 Attribute 也能通过这个方式,可是我们并不知道系统是怎么去给这些带名称的 Attribute 编号排序的,这时候就需要 GetAttributeIndex 来获取这些编号,如图:
改变 Attribute 名称不会让引擎觉得改了东西,也不会进入撤销的操作队列,这是个 Bug,需要我们改变点别的东西才能重新编译。
这里面 GetValue 的节点输入的是 Index 值,SampleValue 输入的是 Unit 值。实际使用上 Index 还是多一些,输入 Unit 只是省去了我们自己转成 Index 的操作而已。而直接操作 GridValue 还是没有指定 Attribute 名称方便,因为这个名称是编译期解析的,不用担心运行时效率的问题。
还有两个特殊的节点,ClearCell 是直接给当前 Grid 的坐标 0 值,而 CopyPrevioustoCurrentforCell 则是把初始值赋给目标 UAV 。实际上之前提到的一开始目标 UAV 的初始赋值就是拿这个函数来做的,也因此我们一般用不到这个节点。
最后来讲一下这些函数在 HLSL 中的写法。一般来说 UE 的这些函数有一个通用的使用规则,即Object.FunctionName(Input1, Input2, … Output1, Output2, …)。其中这些编号就是蓝图节点从上到下排序的,Grid 输入不算在内。其他需要这样写的都基本是同一个思路,只要符合这个基本写法的今后我们一概不再赘述。
Attribute 是一个需要特殊注意的写法,但凡带 Attribute 的一般我们需要在函数名后面注明,即Object.FunctionName<Attribute=”AttributeName”>(…)。引号可以不加。
另外还有一个注意事项是,所有输出都不是函数返回值,而是在函数参数中以 out 为标记的项,部分非 inout 的纯 out 参数,传入的变量声明时不能有初始值。
第七章:Grid2D Collection Reader
定义到 System 里面的 Grid2D 不能直接做遍历不方便,所以还是要在 Emitter 中定义。而一个 Emitter 读不到另一个 Emitter 的变量,这个时候就有一个类似AttributeReader 的 Grid2DReader 诞生了。
Grid2DReader 的使用方法与 Grid2D 一样,一开始我们需要在 EmitterSpawn 中 Set 它:
非常简单,一个是 Emitter 名称,一个是 Grid2D 名称。设置好后我们就能像 Grid2D 一样使用它,所有节点都一样,但是目前还不支持根据 Attribute 名称来获取或设置值,不太方便。
另外 Reader 也是可以作为遍历的参数的。
第八章:Grid3D Collection
Grid3D 就是 Grid2D 多了一个维度,如果 Grid2D 对应 2维贴图,那么 Grid3D 则对应 3D 纹理。同样我们进行 Set,会发现多了一些设置选项:
Set Resolution Method 的三个选项分别对应上面三种设置方式。其中 Num Cells 我们比较熟悉,就是直接设定每个维度的分辨率。CellSize 需要配合下面的 WorldBBoxSize 使用,最终的分辨率将会是二者的商,我们在 Grid2D 的节点中也见过获取这两个信息的节点,而实际上它们在三维空间上才有真正的意义,这相当于是我们给每个像素设定了一个实际的世界坐标,而它的应用在 NeighborGrid3D 中才会体现出来,这里我们暂时略过。最后是 Num Cells Max Axis,他也是依照 WorldBBoxSize 的三个坐标之间的比例,把最长的轴的分辨率设为这个值,其他的轴按对应比例缩减。
然后我们看 Grid3DCollection 项目中的 RenderTargetUserParameter,它的使用跟 Grid2D 相同,只不过我们需要创建一个外部的 Volume Render Target 来对应显示,二维的 RT 是无效的。NumAttribute 还是会把 X 轴进行拉伸放大。
Volume Render Target 的预览问题我们这里也提一下。首先预览的形状跟实际的大小是不一样的,实际的大小是 (X * Num Attributes) * Y * Z,而显示的大小是 (X * Num Attributes) * (X * Num Attributes) * Z。表现层上,一张二维图片被分割成好多个小正方形,每个正方形对应着一个 Z 轴分量,正方形内部相当于一个 Grid2D,Y 对应纵轴,被等比例缩小成了 X 轴的大小,但是相对位置是正确的。X 轴处理 NumAttributes 的排布方式与 Grid2D 相同,下图是把 Number Attributes 设为 4 时,把序号为 1 的 Attribute 设为 1 时的图像表现:
除了多了一个维度外,Grid3D 的蓝图节点使用与 HLSL 代码基本相同,同时 Grid3D 也暂时不支持使用 Attribute 的名称来设置变量。另外 Grid3D 的默认 Float 精度是 4 位字节,当然也是可以修改的。
有一个额外需要注意的节点时 SetGridValue 的节点:
可以看到这个节点没有流程线,所以我们必须把一个输出给到一个局部变量上才能让这个节点被真正运算,这个输出的 IGNORE 是 0。这很奇怪,建议官方修复。
第九章:Neighbor Grid3D
Neighbor Grid3D 是 Grid3D 的特化版,因为我们的 Grid3D 已经有了能给空间划分区域的概念,而这种区域划分是加快空间邻近搜索的一大杀器。为此我们加入了一些方便做这样的运算的函数,来辅助我们处理空间邻域搜索。
它无需设置 Num Attributes,也没有 Attributes 的概念,相对的它有一个 Max Neighbors Per Cell 的概念。对实际的数据结构来说,它们的意义是相同的,都是决定每个 Cell 的大小,而在实际数据的空间排布处理上,不同于 Grid2D 与 Grid3D 把不同的 Attributes 在空间上割开,NeighborGrid 则是把同一个坐标的值聚集到一起来储存,体现邻域的特点:
NeighborGrid3D 还有一个重要的区别是不需要设置精度。相对于 Grid2D 与 Grid3D 只能存 Float , NeighborGrid3D 只能存整型数据并且一定是 4 个字节的整型数据。
另外 NeighborGrid3D 不支持外链 RT 显示,也是因为储存的是大整型数据的原因,在图片上无法做合理的区分。
实际使用上,首先我们的 WorldBBoxSize 就是我们想定的粒子的最大运动范围,给定每个粒子的坐标,我们可以做一些简单的除法得到粒子应该位于哪个 Cell 上。我们可以把这个计算储存到矩阵中,并追加一些原点平移与旋转的配置,然后使用 SimulationToUnit 来计算。这个矩阵的生成也没有官方现成的模块,我们只能在 ContentExample 中找到 InitializeNeighborGrid 模块来参考。
映射到具体的 Cell 上后我们就可以把粒子储存进去了。因为只能存整型,所以一般我们会存 ExecutionIndex,亦或是粒子独一无二的整型 ID。具体来说,NeighborGrid3D 在每一个 Cell 上维护了一个动态大小的数组。虽说是动态大小,但是实际上储存空间早就按最大大小 Max Neighbors Per Cell 来申请,只是我们额外再每个 Cell 维护了一个当前已储存的数据的数量。
遍历时 ExecutionIndex 不会对每个 Cell 内的数据遍历,而只是对 XYZ 遍历,但我们会有获取每个 Cell 下的某个粒子的需求。在 NeighborGrid3D 中,取值不会通过Unit 或 Index 来取值,而只能通过 Linear 来取值,那么我们必须把 Index 与当前 Cell 下的第 Neighbor 个数据值转换成 Linear,实现这个功能的节点叫NeighborGridIndextoLinear。这个计算其实也是十分简单的乘法与加法计算,Neighbor 就是在最后额外追加的一个偏移量:
有了 Linear 我们就可以直接 Get 与 Set 了:
这里需要注意的是只有 Get 是需要带 Neighbor 偏移的 Linear ,而 Set 输入的 Linear 是不带 Neighbor 偏移的(为什么接口都不统一一下?)。Set 函数同样没有流程线,需要我们把 IGNORE 输出给一个临时 Local 变量,而输出的 IGNORE 是 0。
最后我们需要得知我们要往一个 Cell 的哪个位置储存一个最新的数据,为此我们需要获得当前已经储存了的数据的数量,然后在下一个位置储存我们最新的数据。这个数据数量是拿另一张相同大小的表来储存的,也需要我们进行 Get 与 Set 操作(这里的 Linear 都不带 Neighbor 偏移):
与把读位置与写位置区分开的 Grid2D、Grid3D不同,NeighborGrid3D 在同一个执行阶段的读写位置是相同的,这是因为当我们把粒子储存到 Neighbor 中时,我们最大的需求就是实时感知当前 Cell 的粒子数量与粒子的数据,以帮助我们正确地储存我们的粒子。我们不能因为初始时 Cell 里的数据数量为 0 ,结果应该储存到这个 Cell 中的 N 个粒子都读到粒子为 0 ,就都存到第一个位置上,最终只储存了一个粒子。可能产生冲突的读与写是不可避免的,且读与写的交互必须是在同一个数据上实时进行的。
为此 UE 在整个 Niagara 的 UAV 体系中提供了唯一的一个具有线程锁的操作,即 SetParticleNeighborCount。这个函数还能额外提供 Increment 参数指定我们需要追加的数据数量,一次增加多个粒子数据,避免反复上锁解锁带来的开销。我们追加每个 Cell 的粒子数量时,一定不能手动添加,而是要通过这个节点来完成。
一般来说我们会有需要遍历当前 Cell 粒子数据的情况,这个时候就需要 For 循环了,也就是只能通过 CustomHLSL 来完成了,所以 NeighborGrid3D 在 HLSL 中使用的更多一些。遍历时通过 GetParticleNeighborCount 获取实际的粒子数量而不使用Max 能够帮助我们节约一些计算时间。
最后值得一提的是,NeighborGrid3D 的生成代码,在 Linear 与 Index 转换上并没有做到乘法与加法数量的极致,但是本人手动展开这个函数并尝试优化时,发现会对错误的位置进行读写,原因不明,因此大家尽可能还是选择官方提供的节点来完成这些计算,不要去手动计算他们。
而不管是 Grid2D、Grid3D 还是 NeighborGrid3D,由于其储存是线性空间下的,因此对于越界操作并不敏感,即使真的超过了最终的 0 与最大值的边界也不会崩溃报错。但是我们最好还是保持良好的编程习惯,考虑好边界的问题,因为这种越界操作会影响执行效率。
第十章:结语
Niagara 中的 UAV 给了我们更大的可能性,帮助我们建立了粒子与粒子之间的联系,让很多复杂的 2维、3维快速动力学结算成为可能,这将会成为打破实时渲染与离线渲染边界的重要里程碑之一,也会在今后成为高质量游戏的标配。
我们通过 UAV 输出的 RenderTarget,可以用于材质系统的任意位置,让一些十分 Cheap 的材质效果在不同物体间的交互成为了可能
而 Niagara 所提供的还远不止于此,它几乎能与这个引擎的任一功能点进行数据交互,成为了除了逻辑蓝图外的第二个资源信息中转站,接下来这些功能也会被逐一介绍。