【转载】UE4:Niagara 的变量与 HLSL(四)

712 阅读34分钟

原文链接:《UE4:Niagara 的变量与 HLSL》 | 作者:Feilon

Edition 1.00.  Base on UE Ver.4.26

第一章:概述

本文将会是基础篇章的最后一部分,重点讲一下 Niagara 的变量作用域读写权限的划分原理,以及在 Niagara 的蓝图中插入 HLSL 的一些使用方法

本文也是为了之后更好地分模块讲解做铺垫的,之后不会再反复强调这些基础内容。但是本文不会涉及具体的 HLSL 语法,需要这方面内容的同学自行网络搜索即可。

第二章:Niagara 的变量概述

在 Niagara 中,所以粒子系统相关的状态都通过变量来传递,这些变量全部通过前端界面向我们展露了出来,使得我们获得了对所有变量的完整的控制权。

Niagara 会将粒子进行正常不进行任何变化所需要的所有变量全部暴露出来(这句话是作者笔误?),因此我们在没有创建任何一个变量时,就已经拥有大量可以访问的粒子系统的各级属性,它们不需要我们为其进行赋值、考虑它们应该如何变化,我们直接使用这些变量即可,当然我们也可以人为地改变这些值,进而影响整个粒子的运行逻辑(最好不要这样做)。

然而,由于粒子系统特殊的组成结构与执行逻辑,如果我们真的任意地改变所有的变量,必定会造成重大的逻辑错误,导致程序崩溃。因此 Niagara 对不同类型的变量给予了不同的命名空间,不同的命名空间一般意味着在同一帧不同的执行时间点,有不同的读写权限,防止我们进行错误的读写操作。同时还有一些特殊的命名空间意味着变量特殊的生命周期、特殊用途等。

当我们创建一个变量时,我们有必要去思考其应该属于哪个命名空间。当我们使用一个已有变量时,也需要注意我们是否能够拥有可访问的读写权限,也需要注意我们读取到的变量是哪一帧的值,这个值是否是有效值。

读写权限的区分,其实很容易被理解,这跟 CPU 对多线程读写的理解几乎相同,只不过我们对所有可能引发线程写冲突的操作都进行了禁止。GPU 上可以进行同时读写的资源是有限的,叫做 Unordered access View,即可在任一个计算单元访问并读写其他任意一个计算单元。在 Niagara 中这样的资源是一种特殊的类型,叫 Grid2DGrid3D 等。一般来说,我们的设计应该尽量避免使用这样的资源,因为我们无法控制并行运算时写的顺序,即使我们使用原子操作,这样的操作还是会降低并行运算的效率。

这样针对一个粒子系统中不同的实体层级,比如粒子系统本身,比如粒子发射器,比如粒子,因为发射器之间、粒子之间的并行逻辑关系,就自然而然地有了相应的读写限制

如果你是程序出身,明白了上面这段话,那么基本上就明白了所有关于读写权限的内容,可以跳过接下来的相关内容。如果没有明白,接下来会继续展开讲这部分内容。首先我们再来看一下粒子系统中变量可以拥有的命名空间种类

image.png

image.png

其中上边的图是粒子中可定义与访问的命名空间种类,下面是 Niagara 蓝图中可以定义与访问的命名空间种类。我们可以直接在对应的命名空间添加变量,也可以右键变量修改他们的命名空间。接下来我们会依照重要性逐个讲解。

第三章:System

标记为 System 的变量是属于粒子系统整体的全局变量,默认情况下,一个空的粒子系统自带的 System 变量属性有以下这些:

image.png

我们这里先讲解一下每个变量前后的标记。首先最前方的色块有着跟一般蓝图相同的变量标记语义,表示该变量的数据类型。中间的 SYSTEM 标记了当前变量的命名空间,如果有多级命名空间会在这里依次罗列。之后是变量名。变量名后面的小锁意味着当前变量已经不可被修改名称与命名空间,这是因为当前变量已经在粒子系统中被强引用。而最后面的数字则是变量在粒子系统中被引用的次数。

有些时候,被引用的变量还是能修改名称与命名空间,这是因为引用是弱引用。某些情况下,进行弱引用的位置并不会随着变量名称与命名空间的改变而做出相应改变,此时该位置会引用一个空值。因此当引用次数不为 0 时,尽量不要对变量的名称与命名空间做出修改。

因为一个粒子系统中只有一个粒子系统(这是废话),因此 System 的变量在当前粒子系统中只拥有一份,所以是全局变量,可以用来保存一些全局需要的属性。

然后我们来看看 System 的值的读写权限。命名空间为 System 的变量,只能在 SystemSpawnSystemUpdate 执行模块中进行完整的读写操作,而在其他任何执行模块中都只能进行读操作

这是因为,其他任何执行模块,包括归属于 Emitter 或 Particle 的执行模块,都有可能是并行操作的。举个例子,如果我们在 Particle 的某个执行蓝图中修改 System 的变量,而我们有 1000 个粒子,这意味着每个粒子执行这个蓝图时,我们都会对这个 System 的变量进行一次修改。假如我们想将他的值写为粒子的颜色,由于整个执行过程是并行的,我们无法确定最终到底写入的是哪个粒子的颜色,因此这样的值对我们来说也没有任何意义。

当然我们还是有一些全局记录的需求的,比如粒子的总数。我们可能希望在每个粒子 Spawn 时将这个 System 所属的粒子总数加 1 ,从而记录一个粒子总数。然而我们的粒子 Spawn 是并行的,假如当前粒子数为 0 ,我们接下来会同时执行 100 个粒子的 Spawn 操作,他们会同时读取当前的粒子总数,都为 0 ,然后对这个 0 执行加 1 ,再将 1 写入这个变量,于是我们Spawn了 100 个粒子,却只会得到粒子总数为 1 。因此这样的写操作也是没有任何意义的。

没有意义的操作都会被禁止,因此我们在其他任何可能存在并行运行、无法决定其顺序的执行模块上,都无法修改这些全局唯一的 System 变量。

一般来说,我们很少创建 System 变量,除非我们有一些变量需要在多个 Emitter 之间共享读取。因为 Emitter 也是无法修改这样的变量,所以使用的场景十分有限。并且很多时候我们只需要一个 Emitter ,此时就把变量的命名空间设为 Emitter 即可。

话虽如此,UE 提供的默认的 System 变量还是很有意义的,因为这些变量记录了一个粒子系统当前的运行状态,而我们的粒子变化总是或多或少跟某些全局的运行状态相关。

其中最重要的一个便是 Age,即系统当前的运行时间,是最常用的变量。

其它的变量用到的地方较少,但是也可能会有一些特殊的需求拿它们做一些trick,亦或是某些通用性的设置。关于这些变量以后会在其他文章中具体讲解(其实看变量名就能大概知道它们是什么意思),某些设置相关的变量也会在讲到相关内容时额外提到,此处暂且略过这些变量的详细含义,专注于对命名空间的区分。

第四章:Emitter

Emitter 是我们除了 Particle 最常用到的命名空间,是比 System 更加常用的全局类型的变量。一个粒子系统有一个 Emitter,就有几份所有 Emitter 命名空间下的变量,每个 Emitter 的变量都属于其自己,拥有不同的值,不可被其他的 Emitter 之间读取与修改(当然借助其他办法还是可以传递的)

创建一个 CompletelyEmpty 的 Emitter,我们会发现 Emitter 命名空间下没有任何默认的变量,这是因为一个空的 Emitter 不包含 Emitter State 执行逻辑。当我们把这个 Module 添加到 Emitter Update 中后,我们就能看到一般意义上的默认变量了:

image.png

可以看到这些变量跟 System 中的默认变量几乎相同,这是因为一个空的粒子系统必定会自带 System State 模块,在 System Update 里。正如我们之前介绍的,添加的 Module Script 中的变量,在使用 Module 时会自动添加到粒子系统的变量中,并拥有相同的命名空间。两个 State 模块定义的是几乎相同的内容,它们都决定了一个粒子系统的全局运行逻辑,因此有着近乎相同的变量。但是 System State必须要有的,删除这个模块会出现编译错误,因为这相当于粒子系统运行方式未定义,反之 Emitter State不是必要的,没有的情况下默认使用 System State 中定义的执行逻辑。

其中我们最常用的还是 Age,它是粒子发射器的当前运行时间。

当我们选中不同的粒子发射器时,变量右侧会显示当前粒子发射器的引用次数,以及有锁表示是否可修改名称与命名空间等。我们通过粒子系统的变量添加方式添加的 Emitter 变量,在所有的 Emitter 中都会生成一份变量,而我们通过添加模块自动添加的变量,则不会复制到另一个 Emitter 中,当我们选中另一个 Emitter 时也看不到这些变量。

需要注意的是虽然全局添加的变量对每个 Emitter 都会复制一份,但是实际上每个 Emitter 是可以对这个变量做单独修改的。假如我们的变量的引用次数在每个 Emitter 中都 0 ,对其中一个 Emitter 中的全局 Emitter 变量修改,另一个 Emitter 中的对应变量也会被修改成同样的名称与命名空间。但假如其中一个 Emitter 对该变量的引用次数大于 0 ,此时在引用次数大于 0 的 Emitter 中修改变量的名称与命名空间,会传递到引用次数为 0 的 Emitter 中;而如果在引用次数 0 的 Emitter 中修改,则相当于创建了一个拥有新的名称与命名空间的变量并删除原变量,存在引用的 Emitter 在保持原有变量不变的同时,还会多出一个拥有新名称与命名空间的变量。

如果你被上面这段话绕晕了,那么就尽量不要做这种奇怪的操作。

下面看 Emitter 的读写权限。

首先 System SpawnSystem Update无法对 Emitter 的变量进行读写的,原因很简单,虽然它们的执行更靠前,没有并行冲突的问题,但是我们无法指定到底读写哪个 Emitter 中的这个变量,Emitter 中是否有这个变量,甚至可能这个 Emitter 还没有被 Spawn 。

我们在 EmitterSpawnEmitterUpdate 中可以获得对 Emitter 变量完整的读写权限,但是也只是对当前 Emitter 的这份变量进行读写,与其他的 Emitter 没有任何关系

我们在 ParticleSpawnParticleUpdate 乃至后面的其他执行逻辑中都只能对 Emitter 变量进行读取操作,这也是因为并行修改的冲突问题导致的,之前已经详细讨论过原因,此处不再赘述。

第五章:Particle

Particle 是我们最常用到的命名空间,因为我们控制粒子运动无时无刻不在跟 Particle 的状态打交道。每个 Particle 都有一份属于自己的 Particle 变量,无论我们定义了多少变量,因此这是最占用计算资源的命名空间。Particle 变量的增删也是以 Emitter 为单位进行的,即我们可以为不同的 Emitter 定义不同的 Particle 的变量,但是同一个 Emitter 下的所有 Particle 必定拥有所有定义好的变量属性。

我们需要添加 ParticleState 模块才能看到 Particle 的默认属性:

image.png

由于我们不需要在粒子中设置整体的运行逻辑,因此相关的变量都不存在,有的仅仅是跟粒子生命周期相关的内容,其中 Age 为当前粒子当前的运行时间,Lifetime 为当前粒子的最大生存时间,NormalizedAge 为前述二者的商,位于 0 到 1 之间。

当然仅仅这些变量一般是不够,我们还可以在 ParticleSpawn 中添加 Initialize Particle 模块,来获得更多的变量:

image.png

一般来说,这些就是我们最常用的粒子状态了。当然我们也可以手动添加这些变量,这样可以对这些变量按需做取舍,减少一些不必要的计算资源的浪费。

关于读写规则,System 与 Emitter 相关的执行模块都不能对 Particle 变量进行读写,原因之前分析过,不再赘述。

ParticleSpawnParticleUpdate 对 Particle 类型的变量有完整的读写权,SimulationStage 中对 Particle 遍历时也有完整的读写权,因为他们都只对自己的粒子进行操作,没有并行运算冲突的问题。

但是 SimulationStage 中对其他运算资源的遍历过程,则不可以对 Particle 变量进行读写,因为你无法指定读写的是哪个粒子,且同时有并行运算冲突的问题存在。

通常我们每个粒子不能获得其他粒子的状态,更不能获得其他 Emitter 中的粒子的状态,但是通过 AttributeReader 可以达成这个效果,我们之前已经简单提过,这里不再深入。

以上所有 System、Emitter、Particle 的变量,都是从其对应实体 Spawn 后就一直存在的,直到对应实体到达最大寿命后消亡。这意味着当前模块修改的值,下一模块读取到还是这个值,跨帧时值也能保留。这可能是很自然的,但是我们之后也会看到一些不可以跨模块或跨帧的变量的命名空间。

第六章:Engine Provided

我们的 Niagara 最厉害的地方之一在于,即使是 SystemState 这种必不可少的最基础的模块,双击进去也是通过 Niagara 的蓝图脚本来实现的。这让我们对任何执行逻辑都可以方便地进行溯源。

然而既然是这样,那么我们刚才提到的一些最基本的变量如 Age 也是通过这些脚本运算得到的。为了运算得到 Age,我们必不可少地需要访问一些引擎全局的变量:帧与帧之间的间隔时间

在蓝图或 C++ 中这个间隔时间我们通过 Tick 函数的输入参数来获取,而 Niagara 的蓝图时没有 Tick 函数的。为此我们需要特别地追加对引擎全局变量的访问接口,这个命名空间就叫做 Engine Provided

默认情况下,Engine Provided 只有三个变量:

image.png

而实际上,我们能够获得的引擎提供的变量接口远远不止这些,这些变量都可以通过右上角的加号来选择添加。需要注意的是,由于 Engine Provided 是引擎提供的变量接口,因此我们是不可以添加自定义的引擎变量接口的,这也没什么意义。我们只能在这里添加已有的接口类型。部分变量如下所示:

image.png

我们可以看到很多的 Engine Provided 变量有一个二级命名空间,有一些二级命名空间是我们熟悉的命名空间,如 SystemEmitter 。实际上,在 Niagara 中,二级命名空间一般不具有实际的分类意义,并且可被我们自定义添加,甚至是追加三级四级,但是官方提供的二级命名空间一般都指该变量的来源

在上面的这些变量中,给出 System 与 Emitter 的二级命名空间的变量,显然就是来自对应的命名空间的变量,有重叠的也有完全独立的。我们通常可以在这里访问一些编译运行时会收集的更为全局的粒子系统与发射器的信息。

值的注意的是 Owner 这个我们之前没见过的命名空间,它指代我们的 Niagara 粒子系统的父级内容的属性。

一般来说,UE4 世界中存在的实体都是 Actor ,而 Niagara 不管以什么方式加入世界中,都会以 Actor 中的 Niagara Component 的形式存在,我们的 Owner 即指代这个 Component 。这意味着我们能通过这些变量,获得粒子系统在世界中的位置、速度甚至 LOD 等信息,是做某些效果或泛用化时必不可少的内容。

关于 Engine Provided 变量的读写限制,很显然,因为是引擎提供的纯接口变量,我们是不可能对这些变量进行改变的,即使它是来自 System 的 Age。相反,因为它的层级最高,我们在粒子系统的任何一个地方都可以进行操作。

第七章:User Exposed

然而我们就是希望能有一些自定义的 Engine Provided 变量,它们可能来自粒子系统外的某个地方,或是蓝图,或是 C++ ,或是某种类型的资源等。它们被我们以人为的方式赋值,甚至有可能在外界随运行时改变。然后我们将这些变量传递给粒子系统,影响粒子系统的运行。这就是 User Exposed 。

User Exposed 是一个特殊的命名空间,冠以这个命名空间的变量,意味着该变量对外界暴露,可被粒子系统外的蓝图、C++、其他资产读写。但是它同 Engine Provided 一样,我们在整个粒子系统中都只能对这些变量进行读操作。

然而我们添加了这些变量后,在外面是否对这些变量进行赋值是不确定的,因此在粒子系统中我们至少要给定一个它们的默认值。进行默认值设置的位置在粒子系统面板,SystemSettings 下的 User Parameters 模块中进行。所有的 User Exposed 变量在此进行统一设置。

我们已经知道粒子系统与外界进行参数交流还可以通过 NPC 进行,NPC 也是一个粒子系统全局只读的变量,这一点跟 User Exposed 很像,但 User Exposed 是绑定到固定的某个粒子系统上的,并且 Spawn 多个同类的粒子系统,他们的 User Exposed 变量是互不相干的,而 NPC 则是引擎全局的,是所有使用到它的粒子系统共享的,一旦修改会影响所有使用到的粒子系统。

并且 User Exposed 在实际使用时也存在一些问题。因为假如我们希望通过蓝图 Spawn 一个粒子系统出来,并通过 User Exposed 给予其某些初始值。道理上讲,我们应该至少在粒子系统产生之前或构造时给定这些参数,然而我们必须 Spawn 后才能拿到粒子系统的指针,才能对绑定到某个粒子系统实例上的 User Exposed 变量进行设置。而在我们 Spawn 粒子系统时,粒子系统内部至少可能已经走完了 Emitter Spawn 的流程,即已经结束了初始化,那么我们之后设置 User Exposed 便毫无意义。当然如果我们的 User Exposed 变量是用在某些 Update 的逻辑中,就不会受到影响。

明白为什么会不好使,就很容易构思回避的方法。我比较喜欢的方法是通过 NPC 来在 Spawn 前进行设置,其他的方式总觉得不那么直接。

另外 User Exposed 不能直接在 Script 中使用,必须通过给 Module Input 赋值才可使用。

第八章:Stack Context

这个类型的变量同 NPC 一样默认被隐藏,需要点击小眼睛才能看到。

从我们制作粒子系统的角度上讲,这个命名空间没有给我们提供任何额外的功能,因此我们完全可以不使用这个命名空间。那么它到底会赋予变量什么功能哪?

与其说是赋予变量功能,不如说是赋予编译器什么功能。因为这个命名空间的变量,会随着你使用它的位置,将当前位置对应的命名空间赋予自己。

举个例子,我们添加了一个 Stack Context 变量 Velocity,然后再 SystemSpawnSystemUpdate 里读写这个变量。此时这个变量的命名空间就是 System 。相应的我们在 Emitter 或 Particle 的对应位置读写这个变量,其命名空间就会相应变成 Emitter 或 Particle 。这相当于,你在 System、Emitter、Particle 三个命名空间中分别创建了一个名为 Velocity 的变量,然后各自独立地使用它们。而使用了Stack Context,你只需要创建一个这样的变量。当然实际表现并不会因为你只创建了一个 Velocity 变量,导致三个命名空间的 Velocity 的值有任何的相关性。

所以说白了这就是个懒人命名空间,当你有一些各层级实体都可能具备的一些属性,不想每个命名空间都分别创建这些变量,不想随着算法的迭代去调整到底哪个命名空间应该保留这个变量时,就可以使用 StackContext 命名空间。

而正是因为这一特性,当我们在任一位置使用 StackContext 变量时,StackContext 变量本身并不会产生实际的引用,而是自动在对应的命名空间生成同名的变量并带有引用次数。当我们清空对 StackContext 变量在某个位置的使用时,对应命名空间的同名变量会被自动删除。假如我们在使用 StackContext 变量后,在变量列表中删除了这个变量,我们还是能正常在模块中使用已被使用的 StackContext 变量,它们在蓝图页面还是显示为 StackContext 命名空间,但是实际上它们的命名空间为对应位置的命名空间。

还值的一提的是 SimulationStage 中对 StackContext 变量的使用。规则是相似的,当我们在 SimulationStage 中对 Particle 进行遍历,那么此时该变量的命名空间为 Particle 。而如果我们是对其他中间数据类型做遍历,比如 Grid2D,那么对应的命名空间为对应中间数据所处的命名空间,加上一个以中间数据类型的变量名为名称的二级命名空间。此时这个变量实际上成为了 Grid2D 的一个属性,每个格子都有一份这个变量。至于这个到底怎么理解,等讲 Grid2D 的时候再说。

以下是上面讲的这一大堆的展示图:

image.png

如果上面这些没看懂,那么就不要使用这个内容,因为这并不给粒子系统添加任何额外的能力。

第九章:Transient

这个类型的变量也是隐藏变量。而除了我们希望能对性能有极致的追求,一般也不会用到这个命名空间。

一般我们需要什么变量就直接在对应命名空间新建一个变量就可以了。但是某些变量并不是全程都会用到的,有些变量只是在某些阶段存在的一个临时变量,同时其结果可能对下一帧的计算没有任何意义。此时我们就会用到 Transient 变量。

因为是临时变量,所以一旦出了当前的作用域,里面储存的值都会归为默认值。而作用域的转换包括 System 到 Emitter、Emitter 到 Particle、Spawn 到 Update、SimulationStage 到下一个 SimulationStage。如果我们需要使用 Transient 变量,那么必须在作用域转换前来使用这个值,否则值就会丢失。

比较常见的 Transient 值是 PhysicsForce,它会随着我们使用某些官方模块时被自动添加,必须在作用域转换前被作用到速度上,否则值就会消失。

Transient 的变量还有着跟 StackContext 类似的特性,即我们可以在任一模块使用这个变量,它会自动取当前位置对应的命名空间来解释这个变量。不同的是,使用时会增加自身的引用,而不是在其他命名空间自动添加同名变量。

第十章:Module Input

关于这个命名空间我们之前已经讲过很多,它只能在 Script 的界面中找到它,且只在 Dynamic Input ScriptModule Script 中有实际意义,用来给变量设置页面添加相应的变量设置输入接口。我们这里额外讲些设计原则上的问题。

正如我们之前所说,我们可以直接在 Script 中添加 Particle、Emitter、System 的变量,并在使用 Script 时自动创建这些变量,为何还需要 Input 这种人为赋值的方式来指定变量哪? 我们什么时候应该使用 Module Input,什么时候应该直接使用一个 Particle 等类型的变量哪?

问题的核心在于我们创建的这一 Script 所进行的运算,是否跟粒子、发射器、粒子系统这些实体有关。假如这是一个通用性很强的模块,用来做一些抽象的运算,那么我们就应该使用 Module Input ,等到使用该 Script 时再指定输入。如果我们的模块跟某些实体强相关,比如 Particle StateInitialize Particle 等,那么我们就应该直接使用带有特定实体的命名空间变量。而假如我们的模块虽然跟某些实体强相关,但是输入的变量并不一定是某些固有属性固有名称的变量,那么还是应该使用 Module Input 。举个例子,我们的计算需要粒子的位置,可以直接使用粒子变量 Position,然而实际计算过程并不一定是需要粒子当前帧的位置,有可能是需要我们额外保存的上一帧、上上一帧甚至是其他某个修正过的位置,这样就应该使用 Module Input 。否则我们需要为每个特殊情况额外复制一份 Script 。一旦我们需要修改这个 Script ,将会变得十分麻烦,且容易忘记修改某个位置。

第十一章:Module Output

有 Input 必然有 Output ,并且这个 Output 跟 Input 一样,也与我们在 Script 中添加的 Output 不同,不会在我们以函数节点的模式使用时作为引脚出现。

它是与 Module Input 相对应的。一般来说 Module 是作为粒子整个执行过程中的一部分出现的,我们可以给其输入,但是其输出一般就是指对某些命名空间的变量的改变。实在想缩短变量的生命周期我们还有 Transient 。似乎不需要什么 Output 来添加额外的功能。

而实际上 Output 也仅仅是起到了让模块的编辑逻辑更加清晰的作用。假如我们模块的输出结果并不希望绑定到某个实体上使其在整个运行期间一直存在,而是产生一个中间结果,并期望马上被应用,然后马上抛弃。我们可以选择 Transient ,但是使用 Transient 意味着我们在 Module 以外的其他位置甚至执行之前也可以引用甚至修改这个变量。我们不希望产生这样的歧义,此时才会需要 Output 。

Module Output 的变量只能在 Script 的变量列表中添加,当我们在 Script 中使用这个变量后,才能在粒子系统的变量列表中出现,然后被我们在其它位置使用。

Module Output 类型的变量会自动添加一个二级命名空间,这个二级命名空间在 Script 中显示为 Module ,在粒子系统中显示为该变量所处的模块的名称。

因为被特化为输出,因此我们在创建 Output 的 Module 外都只能读变量,只有在那个 Module 内才可以写变量。

而关于它的生命周期与使用方法,则是与 Transient 完全相同的,且它的命名空间属性也跟 Stack Context 一样随着使用的位置发生改变。

值得一提的是,我们希望这个变量能 在 Module 执行后 可以被使用,到当前作用域以外后 不可被使用。而实际上我们可以在任何位置读这个变量,不仅可以在 Module 执行前,甚至把 Particle 中的 Module 产生的 Output 拿到 Emitter 中使用。这其实有点违背设计这个命名空间的理念。当然这些不合逻辑的使用得到的都是无效值,但是编译还是都能正常通过。我们使用的过程中需要引起格外的注意,避免这种错误的使用方式出现。

第十二章:Module Local

一个函数不能声明局部变量肯定是不完整的,我们的 Script 可以声明这样的局部变量来做计算的缓存。与 Transient 不同,它的作用域就仅仅存在于当前的 Script ,出了 Script 就会消失。

它的实际命名空间也跟 Stack Context 一样随着使用位置的不同发生改变。我们可以在 Script 中的变量列表直接添加这个变量,也可以把某个变量线拖到 Map Set 的灰色引脚上,系统会自动帮我们创建一个对应类型的局部变量。当然有些特殊类型的变量还是需要我们手动创建。

关于这个变量,我们再多说一句的是,当我们使用蓝图的连线来进行复杂的运算时,假如我们执行蓝图的方式是预先将其转换为代码,再逐行执行,那么效率会是最高的,材质系统就是这样的实现。而基于虚拟机的蓝图实现,其实现方式类似于自动机、状态机。二者的区别在于,翻译代码的过程可以通过一些算法找到应该缓存中间变量的地方,自动创建局部变量,避免对一些不变的运算做反复的计算,而状态机则不会做这样的处理。

举一个浅显的例子,也是新手连蓝图常犯的错误。假如某几个节点需要一个随机数做输入,一般来讲代码层面我们会先生成这个随机数储存起来,然后后面一直读取这个被储存的数字。连蓝图时,我们可能会直接将随机数生成的节点依次连接后面每个节点的输入上,其结果是,每次蓝图虚拟机索要输入都会重新执行一遍随机数生成,导致所有节点拿到了不同的随机数,最终得到错误的结果。

类似的这样的问题还有很多,问题小的不会有太大影响,问题大的可能会大大影响运行效率甚至出现错误结果。

如何判断节点是否会被反复执行,要看节点是否为流程节点,即是否有白线。白线的节点本身为虚拟机的一个状态,其所有的输出都会被缓存,而被标记为 BlueprintPure 的无白线函数节点连接的内容,都是会被反复计算的。这个时候使用临时变量缓存这些中间结果就显得十分必要了,因为一个变量的 Set 是白线节点。

Niagara 的蓝图虽然没有真正意义上的流程线,但是虚拟机的执行方式还是以白线去安排执行顺序的。因此也需要在合适的时机使用局部变量。

第十三章:Static Switch Input

这个命名空间可以看作是一个特殊的 Module Input 。这个类型的 Input 不能从变量列表中添加,必须在蓝图中搜索 Static Switch 节点创建来添加。

image.png

我们先来介绍这个节点区别于一般 Module Input 的地方。首先被添加的 Static Switch Input 变量同 Module Input 一样在 Module 的变量设置页面上找到。其不同在于,Static Switch 相当于流程控制的 switch,因此只能选择一些确定性的可被选择的数据,包括 boolintenum 等,但是不能选择 float 这样不能精确比值的类型。

该节点本身类似于一个 Niagara 蓝图中的 if 节点,根据给定的值的不同输出不同的值:

image.png

但是与 if 不同if运行时计算的,而这个节点是编译时决定虚拟机的执行路线的,不被选择的路线的内容不会被编译进去。这也意味着在变量设置页面我们只能给定这些输入一些固定的值,而不可以是可变的变量或通过 Script 决定的值。

在这个节点的设置页面,除了能决定变量的名称外,还可以选择变量的类型与默认值。部分类型可以选择最大分支上限,以及在 Compiler Constant 处选择一些粒子系统固有的属性。

这个变量类型在官方的模块中被大量使用。假如我们的 if 仅仅是为了做一些方法选择、固定情况切换等 运行时不会改变的判断 ,虽然 if 本身不会浪费太多的时间计算,但是累积起来这一个判断加一个跳转还是会有些许影响的。此时就可以使用 Static Switch 。

第十四章:Data Instance

这是最后一个变量命名空间,也是在 Script 中才能添加。而且点击添加会发现只有唯一的一个选择:Alive。注释上说是一个标记当前粒子当前是否存活的 bool 变量,我们按照说明直接使用即可。

这个变量在 Script 中是可以读写的。可以读意味着我们能获取粒子当前的状态。一般来说我们可以通过它获得粒子当前的存活状态,但是因为一般死亡的粒子就不再执行 Script 了,所以 Script 中的判断不常用。

最常用的是在变量设定界面,指定某个 bool 变量时能够找到这个命名空间的变量。这个变量的负值可以用来作为某些事件的触发条件,比如粒子死亡时给蓝图传送数据。

image.png

我们还能在 Script 中对这个变量做写操作,这意味着通过给这个变量写负值,我们能人为地杀死一个粒子。

最后值得一提的是,虽然提示一直是粒子的存活状态,但是我们能在 Emitter 或 System 的脚本中使用这个变量。此时这个变量指代的应该是 Emitter 或 System 的存活状态,但是具体本人没有测试过。如果不是这样的话请告知我。

第十五章:Custom HLSL

讲完命名空间,最后再讲一下如何在 Niagara 的蓝图中插入一段 HLSL 代码。其实说来也很简单,我们直接添加以下节点即可:

image.png

注意这个节点的名字是可以被更改的。

左上角跟右上角的加号我们能分别来添加输入与输出,也可以添加 ParameterMap 类型的输入输出使其成为一个白线节点。

添加的输入输出的变量名称可以直接在中间的文本框中作为变量使用而无需声明。代码中我们必须给输出变量一个值,否则会编译报错。这个编译报错并不会在模块中直接反应出来,而是会在粒子系统的页面也红圈提示。所以看起来像是一个没有任何错误提示的编译报错。但是实际上做了其他任何一个修改后,在下方的 Niagara Log 界面就会闪现一下真正的编译错误。

使用 HLSL 时,很多错误都需要这样来 Debug 。这应该是 Niagara 粒子系统目前的一个 Bug ,非常难受。

并且正如上面注释中的提示,很多内容都是不支持的,因为这个节点也是虚拟机的一部分,所以不是严格意义上的 HLSL 代码,所写内容都必须是虚拟机所支持的流程。虽然上面说多级 iffor 循环、switch 等等基本的内容都是不好使的,但是即使在 CPU 模拟下这些也是好使的。

但是如果要使用纯粹的 GPU 特性,比如使用 Grid、使用 View 等,除了让粒子系统在GPU 模拟下运行外,还必须给我们的 HLSL 追加以下的宏定义:

image.png

这个宏会让中间的代码不为虚拟机做编译,否则无论怎样编译都不会通过。但是同时这也意味着,中间的代码块不会被编译器识别,存在任何的错误都必须进行人工 Debug ,这也是一件挺痛苦的事情,建议在其它的 IDE 中编写后再复制过来,这样至少可以避免一些简单的书写错误。

这段 HLSL 基本上能够实现任何你想在 usf 中实现的功能。这也是因为 Niagara 在渲染管线中非常靠后,所以基本上能直接使用的那些常用变量比如 View ,都可以直接使用,这给 Niagara 带来了更多的可能性,因为这些内容不能通过蓝图访问。

同时由于 Niagara 的蓝图中不存在流程控制的节点,所以想使用 for 循环也只能使用 HLSL 代码块来完成。此外非常复杂的计算判断逻辑,连蓝图总比写代码要更难阅读。但是毕竟目前 HLSL 代码不方便 Debug ,所以这些优劣只能自行取舍了。

而之所以要在这里讲一下这个节点,是因为之后很多高级内容会一起把它们在 HLSL 中的使用方法一起讲一讲,毕竟,这些内容的使用连个 API 都没有,函数名又不一定与蓝图节点相同,只能自己翻源码,着实不太方便。

最后值得一提的是,如果想在这段 HLSL 中自定义函数使用,必须额外定义一个 struct,并在 struct 中定义类内函数,才能使用这个函数。

第十六章:结语

基础部分到这里就结束了,马上就会先开始讲一些高级的新特性,毕竟这些才是 Niagara 目前最亮眼的内容。

之后因为不是基础部分,每篇文章的内容长度会缩减一些。