Niagara 源码架构解析
从git上的信息其实可以看到,Niagara其实在引擎中存在的时间非常久,在4.0版本就已经存在。但是直到2018年的4.17中公开出来。4.17~4.24 为实验版本,4.25 之后正式版本。但是从目前官方的对于Niagara fix的代码提交上看,还是有相当多的编辑器问题修复。
17年才将Niagara从Engine内部移植到插件中。
随着长久的历史变迁,Niagara已经进行了多次的文件位置上的转移,目前的位置是在引擎的插件中。 Niagara在UE4开始就已经在构思做了,目的就是重构继承于UE3的Cascade。Niagara当时的设计目的就是data driven并且扩展性更高的粒子系统,解锁模拟和渲染,所以有图形化的节点能不需要程序来实现模拟的逻辑功能。所以2014年时引擎实现了向量化(指令并行)的虚拟机。 后续为了进一步提高了并行性,加入了compute shader的GPU支持。
本文主要设计Niagara 的主要代码框架结构,对其内部可能的其他一些知识体系。本接下来文章介绍Niagara 的编译和反射系统。因此本文就不在提及过多关于这方面的内容。
我们照例观察一下整个代码的文件组织。
- Niagara:所有的运⾏和逻辑构成 。
- NiagaraAnimNotifies:可以在动画的过程中进行事件通知播放Niagara。
- NiagaraCore:⼀些通⽤的类
- NiagaraEditor:编辑器内的可视化编辑节点。
- NiagaraEditorWidgets:Niagara 的UI 。
- NiagaraShader:Niagara 的shader 。
- NiagaraVertexFactories:组成MeshBatch的顶点⼯⼚。
一.基本概念
1.1system 和Emitter
我们通常在编辑器中使用的是Niagara资源主要有两种,一种是我们的Niagara发射器Emitter,一种是我们的Niagara系统System。并且我们发射器是由我们的Module共同组成,从而完整的形成我们的整个Niagara系统。其设计理念相比与Cascade,拆分的更加的细致,从而能偶比较细致的组合和复用。
我们本次并不关心Module所能代表的发射和编译模块,我们重点关注的是Emitter和System所对应的运行时数据。
所有使用Niagara 的地方都需要我们的UNiagaraComponent来进行,其主要是进行控制和Niagara系统的交互。
我们在编辑器中资源对应的是UNiagaraSystem和UniagaraEmitter。一个UNiagaraComponent会拥有指定的一个UNiagaraSystem对应。但是UNiagaraSystem并不是我们运行时直接使用的数据。我们会在UNiagaraComponent初始化时构建对应UNiagaraSystem的FNiagaraSystemInstance,同样System里面的UNiagaraEmitter会生成对应的FNiagaraEmitterInstance。这如同类和实例的关系一般。
对于每个FSystemInstance,会有⼀个相对应这个System类型的的Simulation,它和Instance并不是⼀对⼀的 关系。如果场景中存在同⼀个UNiagaraSystem的多个实例,其只对应⼀个 FNiagaraSystemSimulation。也就是说,对于数据结构⼀致的实例,会使⽤同⼀个Simulation进⾏模拟。因为相同的System的Spawn和Update与数据存储的大小和类型是相同的。提高缓存一致性和数据的存储效率。
所有的FNiagaraSystemSimulation都由FNiagaraWorldManager统一进行管理。
1.2 运转流程
Niagara将有Initial和Update两个重要阶段。其中Initial只在一开始运行一次,Update则每帧运行。
而根据发生的位置不同分为SystemUpdate,EmitterUpdate和ParticleUpdate,分别代表每个System,每个Emitter,和每个粒子的更新。
例如我们spawn一个NiagaraActor,对应的NiagaraSystem中有两个Emitter,每个Emitter发射1000个粒子。
我们排除一些粒子间事件这种情况。那么当我们在初始化时,会调用一次SystemSpawn,调用各自的Emitter的EmitterSpawn,调用各自粒子的共两千次的ParticleSpawn。之后除非有新的Particle生成会调用particleSpawn,否则不会再调用任何Spawn。
之后,我们每帧都会调用我们的各种Update去更新数据。
1.3 渲染策略
这里忽略Light和component对应的Renderer。我们拥有多种渲染器可供选择。
我们其他MeshDraw一样会拥有⼀个渲染的代理Proxy帮助我们来完成最终的渲染,对于Component来说我们持有的该System 的所有Emiter带有的Renderer都会我们会赋值给Proxy。因此其实我们的最小MeshBatch单位是与Emitter数量相关,其数据被Proxy持有,最终交付渲染,这⾥的渲染和UE4普通的渲染架构并没有什么不同,都是统⼀的框架。
由于Niagara的可编程性,其实现功能的潜力是巨大的。它可以非常复杂,但是我们剖析其本质上是一种空间位置模拟的系统之后,我们就能够抓住其关键就是更新它需要的关键数据。更新FNiagaraDataSet。
二.模拟数据
不论Niagara Emitter有多少的Module,Module里有多少的脚本,脚本中写了多少东西。我们当然要抓住本质数据,其都是更新某个渲染器所需要的必要参数,也就是如下图所示的信息。
也就是说,只有这些信息是每个渲染器所需要的,其会传递给渲染线程并最终提交渲染。任何其他参数仅仅为中间变量。因此我们首先关心的是这个数据是存储在什么地方的。由于一个Emitter可以拥有多个不同的Renderer。因此其本身存储在Emitter级别,而不是在Renderer级别。
因此我们最终传递给渲染线程的数据存储在每个Emitter实例FNiagaraEmitterInstance的FNiagaraDataSet身上。不论是GPU粒子还是CPU粒子。
我们将FNiagaraDataSet的数据作为每帧更新的数据继续传递。FNiagaraDataSet最主要的数据是两个buffer。
其中一个是当前使用的buffer,一个是我们将要写入的buffer。FNiagaraDataBuffer拥有提供CPU粒子或者GPU粒子的数据。如果是CPU粒子,我们将在CPU做计算写入操作,如果是GPU粒子,我们在CPU端只需要把对应的buffer指定就可以了。
从传递的数据来看,如果我们是CPU粒子,将直接把最终的模拟结果对应的buffer传递给renderer。而GPU粒子则没有最终的数据,所以需要传递给渲染线程的数据则更完整一些。 其封装成FNiagaraComputeExecutionContext。
2.1 CPU粒子模拟数据更新
最开始我们有一个全局的管理器FNiagaraWorldManager
。每一个System类型对应的唯一FNiagaraSystemSimulation都将在这里进行注册并且收到它的支配和管理。
我们的FNiagaraSystemSimulation的数据更新分成两个部分。
- Tick_GameThread。这里主要是更新一下系统时间等参数,然后调用FNiagaraSystemInstance的Tick_GameThread。在FNiagaraSystemInstance主要是更新System的Parameters和DataInterface。
- Tick_Concurrent,这个阶段可以不在Game线程完成。
- 对于那些新生成的SystemInstance调用它的spawn script。
- 是调用system update script进行System的更新。
- 将system模拟的结果注入到emitter的绑定数据中。
- 调用FNiagaraSystemInstance的Tick_Concurrent。
在FNiagaraEmitterInstance::PreTick中,最主要的是执行Emitter的Spawn和Update脚本。
最重要的更新是在FNiagaraEmitterInstance::Tick。并且更新我们最终需要提交的数据FNiagaraDataSet。在FNiagaraDataSet中真正存储数据的Buffer为FNiagaraDataBuffer。其为了保证线程间的安全与准确一共有两个。我们将对将要使用的那个buffer其中TryLock,准备进行写入。
之后,按照Emitter释放粒子的数量分配内存。之后绑定Particle 的Update。并执行
其中UpdateExecContext类型为FNiagaraScriptExecutionContext,这是已经经过编译后的VM指令集。我们将上一帧的Buffer当作输入,这一帧将要使用的Buffer当做输出。
之后对于VectorVM来说执行我们的数据计算。最后我们给我们Data解锁。
|
|
2.2 GPU粒子模拟数据更新
我们当然能够选择GPU的模式进行粒子模拟。当我们选择GPU粒子时,其主要区别是粒子的模拟计算将不在CPU端,而是在GPU端进行。我们将不使用VM虚拟机计算每个粒子的模拟数据,而是把这些计算放入并行性更高的计算着色器进行计算。我们每次都仅把一些参数的更新进行更更新,变为FNiagaraGPUSystemTick并进行提交。
当然,我们更新的数据依旧是我们之前的FNiagaraDataSet
。
GPU粒子更新数据的地方在FSceneRenderer中拥有控制GPU粒子的控制器中。
其中存储所有GPU粒子系统,目前如果没有去除掉之前的Cascade粒子系统的话,这里一种两个两个,一个是FFXSystem。另一个是NiagaraEmitterInstanceBatcher。用来控制各自的GPU粒子。
我们模拟数据的时间点其实并不一致,我们的Tick总共有三种模拟触发的实际,分别是
- PreInitViews,这是默认的阶段。在InitView阶段之前。
- PostInitViews,如果这个NiagaraSystem里面所有的Emitter中使用到的一些数据接口是需要在Initview后后面的,那么这个tick将在InitView后面更新。(目前只有一个UNiagaraDataInterfaceCamera为True)
- PostOpaqueRender,如果我们需要DistanceDieldData,或者深度,我们将在非透明的后面进行更新。
不管是在什么阶段,我们都会调用BuildTickStagePasses和Execute。
BuildTickStagePasses这里的主要目的是为每个EmitterInstance传递过来的FNiagaraGPUSystemTick构建pass。我们为每个FNiagaraGPUSystemTick创建自己的ConstantBuffers。绑定CurrentData和DestinationData、分配GPU空间。
当然如果自己有SimStageData阶段,将一并按照顺序加进来等等。
三.渲染数据与提交
3.1 渲染线程数据
对于CPU粒子来说我们需要将模拟完的数据传递给渲染线程。在NiagaraComponent中的SendRenderDynamicData_Concurrent
中触发更新。每个Emitter的渲染器创建自己的DynamicData。
之后在渲染线程进行新旧数据的更迭
从第二个部分,我们已经知道了FNiagaraEmitterInstance中的数据,其会在SendRenderDynamicData_Concurrent
时将组装成FNiagaraDynamicDataBase给予FNiagaraSceneProxy中的FNiagaraRenderer。
|
|
一个粒子系统可以同时具有多个Emitter,而每个Emitter可以拥有多个渲染器,因此我们的FNiagaraSceneProxy需要记录所有的渲染类型,使用FNiagaraRenderer的一个数组来表达。
渲染器的类型可以是
- FNiagaraRendererLights:
- FNiagaraRendererMeshes:
- FNiagaraRendererRibbons
- FNiagaraRendererSprites
- FNiagaraRendererComponents
我们需要传递给GPU一份数据和这份数据的格式声明。数据类型存储在FNiagaraRenderer中。
在 FNiagaraSceneProxy::GetDynamicMeshElements()
中调用每个Renderer的GetDynamicMeshElements
.
每个不同的渲染器有自己的GetDynamicMeshElements
。根据各自的需求,进行不同种类的填充方案。
我们对于粒子的大部分数据都存储在DynamicDataRender中。
如果我们是CPU粒子,我们将直接拿Data的数据。而解析这份数据的格式则根据不同的Renderer对应的VertexFactory进行区分。我们比较在意的是更新提交数据的地方。
3.2.更新Factory
在UE4中粒子系统的渲染流程与一般的Mesh 的流程并没有什么区别。如果自己是非透明的材质,和普通非透明物体的渲染位置一致,如果是透明物体则在透明Pass中进行渲染。因此其最鲜明的特征其实是使用Instance的方案大量的去完成绘制。我们对渲染管线的数据将不再深入探究,仅仅到其生成MeshBatch的阶段。
对于粒子系统填充MeshBatch来讲,首先需要关注是否需要IndirectDraw,如果我们是GPU粒子的话,我们将填充IndirectDraw 的Buffer。然后我们将填充FMeshBatchElement的NumPrimitives和NumInstances。NumPrimitives是三角面数,NumInstances是instance数量。
我们不同的渲染器的值略有不同。这发生在组装MeshBatch的时候,也就是这发生在每个Renderer的GetDynamicMeshElements里。
对于MeshBatch的 Instance属性,每个渲染器各有不同
- Sprite的NumPrimitives是2(两个三角形),NumInstances是发出粒子的数量。
- Ribbon 因为是在CPU填充的顶点和索引,其NumPrimitives是自己的NumIndices/3,NumInstances是1。
- MeshRenderer 的NumPrimitives是对应的Mesh的索引数量,NumInstances是自己粒子的数量。
除了MeshBatch 的通用数据我们需要填充,我们还需要填充他们各自的顶点工厂。其主要的渲染策略选择与其渲染器中选择的材质类型相关,因此都是FMaterialRenderProxy并不算特殊。其最特殊的是Niagara根据不同的渲染器类型,存在不同的渲染VertexFactory。分别是
- FNiagaraSpriteVertexFactory
- FNiagaraRibbonVertexFactory
- FNiagaraMeshVertexFactory
其本身分别对应不同的渲染器。我们以最为常见的Sprite为例。在Shader中,我们需要访问两个Buffer,一个是渲染器的整体数据;一个是提供Instance绘制的Buffer。
在Shader中,我们根据InstanceID的不同,就可以访问不同的粒子渲染数据。