原文链接:《UE4:Niagara 中的粒子间交互》 | 作者:Feilon
Edition 1.00. Base on UE Ver.4.26
第一章:概述
如果说 Niagara 比原来的 Cascade 强的地方到底在哪,除了哪些可能已经脱离了粒子系统之外的功能,最重要的就是 Niagara 的交互性了。
交互性有分为三类,一类是外界环境对粒子的影响,Niagara 中已经有了现成的 Collision 模块让粒子与外界物体产生碰撞,当然我们也可以自己去 DIY 一些碰撞的形式,总的来说可操作的空间还是比较大的。另一类是粒子对外界环境的影响,这类一般只能通过把粒子的信息传输给蓝图来实现,触发的条件就是当前帧产生了碰撞。然而这条路径并没有想象中的那么便捷,我们不能像 Actor 之间的碰撞那样直接获得碰撞的物体,也就不知道应该把碰撞的信息传给谁。最后一类是粒子间的交互,这类交互目前已经有了一套比较成熟的解决方案,也是我们本文重点讲述的内容。
第二章:粒子间交互原理
首先需要明确的是,这里的交互基本上就指碰撞。碰撞分为好几种,一种是刚性的接触碰撞,另一种是弹性的碰撞,二者的相互作用力大小会随着距离变化,并且粒子有重合的可能。无论是哪一种,都相当于我们自己实现了一个处理碰撞的物理引擎。当然在粒子系统中,我们一般只考虑粒子为圆形的碰撞体,这比实现一般性的物理引擎要简单很多,我们判断碰撞条件只需要检测粒子之间的距离即可。
处理碰撞不可回避的问题是查询。我们知道物理碰撞是一个很耗时的操作,尤其是当我们的物体越来越多时,这是因为每个物理体都需要去计算跟其他所有的物体是否有碰撞。一旦物体数量增多,计算复杂度呈平方级别增长,这显然不是我们想要的结果。
一个比较直接的解决办法就是预先给空间分好块。假如我们的物体有速度的上限,那么理论上他不会跟离他特别远的物体产生交互,这也是我们人判断物体是否碰撞比计算机快得多的原因,因为我们只会检查一个物体附近的物体与它是否存在重叠。
在粒子系统中,这个问题会被放大,因为我们的粒子通常会特别的多,因此为了让粒子间的碰撞能够以更为机智的方式进行查询,我们需要能够对三维空间预先进行区域划分,并只对附近区域的粒子检测是否存在碰撞。我们已经有了这样的数据结构,就是之前介绍的NeighborGrid3D,利用它能大幅加速碰撞的查询,从而让大量粒子的碰撞成为了可能。
查询速度提高了,但查询这件事本身在粒子系统中也是一个需要额外处理的内容。因为 GPU 粒子背后的计算原理是 ComputeShader,所以一个粒子对其他粒子信息的读取本身就是一件需要谨慎处理的事情。
我们不能完全放开对其他粒子的读写权限,否则就成为了 UAV ,而我们通常不需要这一紧俏的 GPU 资源。进一步的,我们不能放开任何对其他粒子的写权限,因为这会使运行逻辑变得完全不可控,而我们给每一个操作加锁又会大幅度影响计算效率。
我们只能放开有限的读权限,又不想使用 UAV ,为此我们只能将某个阶段的计算结果整个缓存下来,供下一个阶段使用。假如是一个 Emitter 计算用到了另一个 Emitter 的粒子信息,我们可以推断出它们的执行顺序,让一个去读另一个的结果。而如果是 Emitter 自己读自己的粒子信息,则只能当前帧执行前缓存所有的数据,并在整个 Particle Update 阶段使用这个最初始的数据。这个读取的桥梁就是 Particle Attribute Reader。
在 SimulationStage 前,这是我们唯一的读取其他粒子的手段,因为整个 Particle Update 阶段是一帧里不可拆分、一气呵成的。而 SimulationStage 其实某种程度上讲帮我们把 Particle Update 拆分成了好几份,我们可以手动把我们需要的粒子信息缓存到 Grid 等结构里,让我们能时刻获得最新的其它粒子的数据。
假如不是 GPU 粒子,我们还有一个可以使用的交互办法,即 Event,不过这个方法也有诸多的限制,我们之后再介绍。
而不管是什么方式,目前 Niagara 是无法实现跨 System 的粒子间交互的。如果想让两种粒子产生交互,必须把他们放在同一个 System 下。
第三章:NiagaraID
之前我们介绍过 Execution Index,这里我们再详细展开一下另一个对粒子索引的方式:NiagaraID。
NiagaraID 说实话用起来并不是那么的方便,因为它的组成通过两个 Int 来实现:
其中 Index 与 Execution Index 又没有任何关系,是我们一般意义上的对粒子的唯一编号。而 Acquire Tag 则是独立于 Execution Index 的另一个属性。这两个属性都是在 Particle Spawn 时通过 AcquireID 函数获得,并在粒子的整个生存周期内不会改变。
其中 Index 是从 ID 池子中获取到的值,在 GPU 模拟下,这个值是混乱不可控的,而在 CPU 模拟下,这个值是从 0 开始计算的,但是由于粒子可能死亡,因为我们不能保证从 0 开始的所有编号的粒子都存在。
Acquire Tag 则是更加不可控的一个属性,它等于粒子 Spawn 的那一帧的EmitterTickCounter 。即当前 Emitter 的执行帧数,而我们在之后的帧数中更加无从知晓某个粒子到底在那一帧 Spawn 的。
二者必须完全相等,我们才能指定这个粒子,而二者都是我们很难以简单的逻辑指定的,除非我们事先将这个 ID 缓存到某个其它的结构中,比如 NeighborGrid3D,而我们还必须一下子缓存两个 int。除非我们特别需要对某个特定的粒子通过编号指定的方式持续作用,否则真的不如 Execution Index 实用。
最后,这个属性并不是粒子默认自带的,需要在 Emitter Properties 中勾选如下选项才会存在:
第四章:Particle Attribute Reader
Particle Attribute Reader 在使用上是非常简单的。它也是依靠 Emitter 存在的,并且需要在 Emitter Spawn 中进行 Set,只需要写入目标 Emitter 的名字即可,没写入名字或写入错误名字时会报错:
至于其他的命名空间的变换,基本上同之前讲的 Grid 等内容相同,我们不再赘述,我们不应该那样去使用这个 Reader。
它的节点也非常简单,都是一些 Get 类型的节点,总的来说可以获取的类型包括:Bool、Int、Float、Vector2D、Vector、Vector4、Color、NiagaraID、Quaternion。获取的方式分两种,一种是通过 Execution Index,另一种是通过 NiagaraID 获取。两种方式的差别不大。
在底层,通过 NiagaraID 来获取属性,实际上也会先通过一张 NiagaraID 中的Index 到 Execution Index 的表将 NiagaraID 转换成 Execution Index 来获取之后还会比较通过 Execution Index 查表得到的 Acquire Tag 是否相等,所以 Execution Index 的获取方式效率还会更高一些。
这也意味着实际缓存着粒子属性的数组是以当前帧的 Execution Index 作为索引的,我们不需要顾虑跨帧导致的 Execution Index 对不上的问题。
Get Particle Index 可以获取到 NiagaraID 对应的粒子的 Execution Index ,这个过程也是通过缓存起来的数组来完成的,数组的索引是 NiagaraID 中的 Index 。
而 Get ID at Spawn Index 则没那么好用。我们知道 NiagaraID 的 Index 是在一个空闲 ID 池子中获取的,获取时指定的索引就是 Spawn Index。这个值的计算不是那么单纯的排序过程,而是 UpdateStartInstance + GLinearThreadId - GSpawnStartInstance。由于这几个值我们都不得而知,所以这个节点基本用不上。
上述节点都是带 Attribute 的,因此 HLSL 的写法也是带 Attribute 的写法,与 Grid 相同。需要注意的是 Vector2D、Vector4 等名称与 Grid 不同,使用的是实际的变量类型名,在写 HLSL 时需要注意不要写混。
而输出时会带有一个 bool 变量指示我们给定的索引对应的粒子是否存在。不存在时也不会出错,会给一个默认值为 0 的结果。由于 0 也是一个可能有效的值(即索引为 0 的粒子),因此必须通过 bool 值判断存在性。而值得注意的是 Get Particle Index 没有这个 bool 值输出,这是因为其不存在时默认输出为 -1 ,并不存在索引为 -1 的粒子。
我们不需要担心系统缓存了过多的我们不需要的 Attribute,它只会对我们引用到的 Attribute 变量进行缓存。
除了上述这些节点,还有两个获取粒子数量的节点,其中 Get Num Particles 获取的是当前(同一个 Emitter 则是上一帧存活的粒子数量,而 Get Num Spawned Particles 获取的则是当前帧(同一个 Emitter 则是上一帧)的 Particle Spawn 的执行次数,即当前帧的生成粒子数。假如 Particle Spawn 时让粒子消失了,则这个函数的统计会出现一些错误(具体没有测试)。
而我们这里再次强调的是,同一个 Emitter 下使用该变量,读取到的粒子信息为上一帧的最终结果,又或者称为当前帧的初始结果。我们是无法获取每一帧中间改变后的粒子属性的,即使被 SimulationStage 划分。一般来说这不会出现太大的问题,但是也有会导致结果错误的情况。比如我们如果使用 View 的一些内容对其他粒子做坐标转换,则必须使用上一帧的 View 的数值才能得到正确结果,否则移动摄像机时会出现错误。
最后我们需要额外提一下一个藏得特别深的 Bug ,它的触发条件不确定,但是在偶尔制作 Niagara 崩溃时会出现。这个 Bug 触发后,你会发现本来运行正常的粒子系统,一个粒子都无法生成了,但是没有任何的报错提示。假如你用到了 Particle Attribute Reader ,那么你应该试着删除当前 Emitter 的 Particle Attribute Reader 变量,再重新创建一个变量并设置它。问题可能就会得到解决。这可能是崩溃时生成的 Code 没有与编辑器的内容同步导致的。
第五章:其它注意事项
有了 Particle Attribute Reader,结合 NeighborGrid3D,我们就能对快速找到邻近粒子,根据粒子的位置、速度等属性做我们想做的交互逻辑。还需要注意的是由于粒子的运动并不是连续的,所以我们的粒子间交互同一般碰撞一样,可能出现 “穿模” 等问题。并且交互的结果会根据帧与帧直接的间隔不同产生不同的结果。而游戏的帧与帧直接的间隔甚至会不同,更加剧了这一不稳定性。
我们能人为在 Emitter Properties 中设定如下值,来指定帧与帧直接的最大时间差。超过这个时间差时,Engine Provided 中的 DeltaTime 会取这个最大值:
过小的值会让我们的粒子系统模拟得特别的慢,但是取一个大值的意义又不大,顶多防止某些卡顿现象导致的模拟结果崩坏。
而使用 NeighborGrid3D 时几乎不可避免地会需要跨帧读取结果的问题,我们至少应该保证每一帧的计算的数据来源都依据相同的数据。而至少在我们改变粒子的某个属性前,通过 Attribute Reader 读取到的值与直接使用 Particle 命名空间获取的值是相同的。一旦粒子属性被改变,之后我们就必须严谨地决定我们使用哪一帧的数据进行计算,并有选择地把我们需要的一些数据如 View 等缓存到下一帧。
缓存的时候还需要考虑命名空间的选择,尽量避免把一些全局相同的数据缓存到 Particle 命名空间上,从而使用大量不必要的计算空间。而本人试图在 Emitter 的执行流程中缓存 View 时又失败了,似乎此时还无法获取到这个值。
第六章:结语
正因为粒子系统计算的高速,导致粒子间的交互不是那么的尽如人意。然而至少我们现在有了这些接口,那么就能做很多很多的事情。至于这些内容怎么做,在官方的 Content Example 中都有示例,此处就不再展开了。
其他的交互方式也会在今后的文章中逐一介绍。