本文是对虚幻引擎内容示例(Content Examples)中 Niagara 高级示例部分的拆解笔记。
具体来说,是 Content Examples 项目中 Niagara_Advanced_Particles 地图里的临近网格体部分(Position Based Dynamics、Plexus、Structural Support、Boids)
示例项目 Fab 链接:https://www.fab.com/listings/4d251261-d98c-48e2-baee-8f4e47c67091
什么是 Neighbor Grid 3D | 临近网格体
Neighbor Grid 3D(临近网格体)是 Niagara 中用于空间查询的数据结构。
为什么需要它
- 降低计算复杂度
- 在没有空间分区的情况下,若每个粒子都要感知周围粒子,计算量为 O(n2)(遍历所有粒,参考下面的视频)。使用网格后,查询范围被限制在相邻体素内,复杂度降低至接近 O(n)。
- 突破 GPU 线程限制
- GPU 擅长并行计算,但不擅长全局搜索。网格体提供了一个预排序的查找表,让成千上万个线程能同时在局部区域内高效定位数据。
- 实现复杂的群体智能
- 像 Boids 算法中的对齐(Alignment)和斥力(Separation)需要频繁读取邻居的速度和位置,网格体是支撑实时高密度群体行为的性能底座。
- 优化显存带宽利用
- 通过将空间相近的粒子数据在逻辑上归类,减少了在执行空间查询时对显存的随机访问开销。 ### 它是如何做的
它将三维空间划分为均匀的体素网格,每个体素存储该空间范围内粒子索引的列表。通过这种数据结构,粒子可以高效地查询其邻近的其他粒子,而无需遍历所有粒子进行距离计算。

这为实现粒子间的连线效果、碰撞检测、群体行为等提供了性能优化的基础。
虚幻中,如何使用Neighbor Grid 3D
Neighbor Grid 3D 依赖模拟阶段,而模拟阶段依赖GPU粒子,所以以下发射器都是 GPU 模式
在虚幻 Niagara 中使用 Neighbor Grid 3D 主要分为两个阶段:
- 初始化网格 :创建必要数据结构。
- 查询邻居粒子:从粒子中获取信息,以便后续操作。
下面分别简单介绍这两个步骤的关键部分,具体案例将在 PBD 实例中实现。
初始化网格
初始化阶段需要完成两个任务:
- 创建网格结构:提供一个位置存放网格数据
- 注册粒子:让系统知道,每个网格中都有哪些粒子
1. 创建网格结构
使用 Set Num Cells 节点在 Niagara 系统中创建指定尺寸的
3D 网格。

这个操作通常在 System Update 阶段执行,这是为了: 1. 确保每帧开始时网格处于正确的状态 2. 确保每个发射器都可以获取到相同的 Neighbor Grid
参数说明: - Grid:NeighborGrid3D 接口 - NumCellsX/Y/Z:网格尺寸:X/Y/Z轴上的单元格数量 - MaxNeighborsPerCell:最大邻居数:每个单元格最多能存储的粒子索引数(也就是插槽的数量)
2. 注册粒子到网格
使用 NeighborGrid.AddParticle
将粒子注册到对应的体素中。
1 | `NeighborGrid.AddParticle(in int In_IndexX, in int In_IndexY, in int In_IndexZ, in int In_ParticleIndex, out bool Out_Success); |
- in int In_IndexXYZ:单位空间索引(即 Unit 空间,需要转换)
- in int In_ParticleIndex:粒子索引
- out bool Out_Success:是否成功输出
内部逻辑:
- 根据粒子位置计算体素坐标
(CellX, CellY, CellZ)。 - 计算该 Cell 在线性缓冲区中的起始偏移地址。
- 利用原子操作在该 Cell 的固定容量(Slot)内占用一个坑位。
- 将粒子索引写入该坑位;若坑位已满则返回失败。
查询邻居粒子
查询阶段是实现空间查询的核心,通过读取临近网格数据获取临近粒子的属性,避免遍历所有粒子。
核心思路:循环遍历27个相邻网格体
Neighbor Grid 的核心优势在于将全局搜索转化为局部查询。对于任意一个粒子,其可能存在的邻居粒子只分布在其所在的体素以及周围 26 个相邻体素 中(3×3×3 - 1 = 26),加上自身所在的体素,总共需要检查 27 个体素。
这种方法的计算复杂度从 O(n²) 降低到接近 O(n),因为每个粒子只需要检查固定数量的体素,而不需要遍历整个粒子列表。
具体实现步骤
步骤1:创建粒子属性读取器
首先需要创建一个粒子属性读取器(Particle Attribute Reader),用于读取其他粒子的属性数据
步骤2:遍历27个体素并读取邻居属性
有两种方式进行遍历:
1 | for (int x = -1; x <= 1; x++) |
或者使用提前声明的数组:
1 | const int3 IndexOffsets [ 27 ] = |
这个代码是使用 Neighbor Grid 3D 必要的基础。
注意,这些循环严格来说,不是对当前单元格中实际存在的每个粒子进行一次计算,而是对每个 该单元格被分配的最大容量槽位(Slots) 执行一次。
Position Based Dynamics | 基于位置的动力学
视频中的立方体是对 Neighbor Grid 3D 网格的可视化,但NeighborGrid3D 接口本质是 GPU 内存中的线性缓冲区(Array),用于存储粒子的索引信息。
对 Neighbor Grid 3D 最基本的使用,先来了解如何使用 Neighbor Grid 3D,对于 PDB 的介绍在涉及到 PBD 的部分进行。
发射器概览

Visualize_Grid_Cells_Via_UnitToWorldTransforms:显示 Neighbor Grid 网格 PBD:中间的碰撞小球
初始化网格
1. 创建网格结构
这里直接使用自定义模块 Initialize Neighbor Grid
来初始化,该模块放在发射器更新部分,保证 NeighborGrid3D
变量可以被所有发射器读取。

发射器的参数简介:
一、 Transform(变换参数)
这部分定义了网格在世界空间中“长什么样”、“在哪儿”。
- Grid Extents (网格范围): 定义了网格体在 X、Y、Z
三个方向上的总尺寸(单位通常是厘米/虚幻单位)。
- TA 视角: 如果你的粒子跑出了这个范围,它们将无法被计入邻居查找逻辑。
- Local Pivot (局部中心):
控制网格相对于其位置的偏移。默认
(0.5, 0.5, 0.5)表示网格的中心点就在它的位置属性上(即居中对齐)。 - Offset (偏移/位置): 图中你将其绑定到了
Engine.Owner.Position(发射器所有者的位置)。这意味着网格会跟着物体移动。对于 Boids 系统,这能确保模拟范围始终覆盖在你的“鸟群”周围。 - Rotation (旋转): 网格的旋转。通常保持默认即可,除非你的模拟环境有特定的方向性。
- Coordinate Space (坐标空间): 设置为“世界”。这意味着网格的尺寸和位置都基于世界坐标,这对处理跨发射器的交互非常重要。
二、 Neighbor Grid(核心逻辑参数)
这部分决定了邻居查找的精度和性能开销。
- Define Neighbor Grid (定义邻居网格): 勾选后,Niagara 会在内存中真正分配这块数据结构。
- MaxNeighborsPerCell (单格最大邻居数):
性能关键点!
它定义了每个小格子里最多能存多少个粒子索引。
- 注意: 如果你的粒子非常密集(比如 100
个粒子挤在一个格子里),但这个值设为
10,那么只有前 10 个粒子会被记录,剩下的会被“无视”。 - 优化建议: Boids 系统中,通常设为
8到32之间。
- 注意: 如果你的粒子非常密集(比如 100
个粒子挤在一个格子里),但这个值设为
- NumCells X / Y / Z (网格分辨率)
内部节点概览
-1773557964434.webp)
左边框中,是实现变换参数的部分,右边部分是实现注册网格的关键
-1773558111684.webp)
但其核心也就是前文中提到的 Set Num Cells
,并暴露一些参数。
2. 注册粒子到网格
这里开始向网格中写入数据,在模拟阶段,这里是:PopulateGrid
部分。
-1773558384721.webp)
参数介绍
- NeighborGrid:上一步使用
Initialize Neighbor Grid创建的网格, 是存储粒子数据的位置 - Position:当前粒子的数据,是要存储的数据。
- Simulation To Grid Unit
Transform:需要使用的变换矩阵,用途下文概述,同样是
Initialize Neighbor Grid中创建的 - Use Persistent IDs:是否使用 Niagara ID
模块概览
-1773558818515.webp)
这里我有个疑问:
如图,即使使用了 Niagara ID,也只使用了其中的索引部分,这样之后,两者之间有区别吗?
核心代码
接下来是其中的核心代码部分:
1 | OutPosition = Position; |
让我们先看最后一个函数,也是执行“注册”这一动作的地方:
1 | int IGNORE; |
这一系列接口函数均不之间输出数值,而是更改一个传入的参数,这里的
int IGNORE;替代了bool变量传入。
参数解释:
in int In_IndexX & Y & Z:网格索引in int In_ParticleIndex:粒子索引out bool Out_Success:指示函数是否成功的输出
从输入参数可以看到,我们要输入的是网格索引和粒子索引。
粒子索引可以使用 Niagara ID 的索引或者
Execution Index ,但网格索引无法之间获取,索引接下来需要
根据粒子位置获取网格索引。
具体来说,就是“粒子位置(Position) -> 单位位置(UnitPos) -> 网格索引(Index)”
代码如下:
1 | float3 UnitPos; |
在 SimulationToUnit函数中,使用了
SimulationToUnit 变换矩阵 ##### 其他部分
1 | OutPosition = Position; |
其中,#if GPU_SIMULATION、#endif#endif是一个宏,保证中间的代码在
GPU 模式中运行。
其他部分获取了一些东西,但不知道用在哪里。
实现PBD
PBD 是什么
PBD(基于位置的动力学) 是一种跳过复杂受力计算,直接通过操纵粒子位置来模拟物理现象的算法。与传统动力学不同,它将物理规则转化为约束条件(如保持距离、防止穿透),通过不断将粒子“拉回”到符合规则的位置,确保系统在剧烈运动下依然极度稳定且不崩坏。
在 Unreal Engine 的 Niagara 等实时系统中,PBD 因其计算速度快、抗增加误差能力强而被广泛应用。它通过设置“松弛量”和“迭代次数”来逐步逼近平衡态,非常适合处理布料、流体及复杂的粒子碰撞,是兼顾视觉效果与运行性能的理想选择。
实现 PBD 的基础是:
- 使用 粒子属性阅读器 获取其他粒子的属性
- 在 常规模拟阶段 对粒子进行多次迭代
模拟阶段,目前可以这样理解:可以在一帧中执行多次的区域。这让 PBD 模拟可以快速得到结果。
在一帧中模拟的次数可以在参数中手动配置
参数介绍
-1773571980092.webp)
- 碰撞查询 | Collision Query:代码中并未使用
- 邻域网格 | NeighborGrid:
Initialize Neighbor Grid注册的网格变量 - 粒子属性读取器 | ParticleAttributeReader:前面创建的属性阅读器
- 松弛系数 |
RelaxationAmount:越小反弹的厉害,越大越“柔软”(
OutPosition += 1.0*FinalOffsetVector/ (ConstraintCount * RelaxationAmount);) - 模拟 | Simulate:是否开启模拟,关闭后不再应用位置移动
- 模拟至单位变换矩阵 |
SimulationToUnit:需要使用的变换矩阵,用途下文概述,同样是
Initialize Neighbor Grid中创建的 - 使用持久 ID | Use Persistent IDs
- 必需变量 | Required Variables
- 碰撞半径 | CollisionRadius:通过
Calculate Particle Radius动态计算,计算的基础是Scale属性。而这个示例里,Scale属性又由上面的Calculate Size and Rotational Inertia by Mass决定。 - 质量 |
Mass:用来计算粒子在碰撞中的行为,在这个项目中,是在
Initialize Particle中定义的- 同时,
Initialize Particle中定义的 Mass 还决定了粒子的大小
- 同时,
- 位置 | Position
- 是否固定 | Unyielding:布尔值,true时,粒子无法被移动
- 固定系数 | Unyielding
Percentage:默认为0,指粒子不被影响的程度,和上面的
Unyielding共同决定被影响程度 - 速度 | Velocity
- 碰撞半径 | CollisionRadius:通过
代码一览
1 | OutPosition = Position; |
接下来是三个案例,在下一篇文章中研究。
附:Debug 网格体
官方提供的
Visualize_Grid_Cells_Via_UnitToWorldTransforms
发射器很简单,级别逻辑就是使用 Neighbor Grid 3D
的各种基本数据(网格大小、网格数量),渲染真实的网格,达到可视化的目的。
如图,即使使用了
Niagara ID,也只使用了其中的索引部分,这样之后,两者之间有区别吗?