教程:自定义粒子
中级 美术 程序员
本教程将展示如何为粒子系统创建自定义扩展,提供核心引擎中没有的功能。
如果你不熟悉编辑粒子,请参见创建粒子。
首先创建一个新的 Sample: Particles 项目。

此项目包含不同的场景,演示粒子的不同使用方式。打开 CustomParticles 场景。
场景中有三个粒子实体:ConeEmitter15、ConeEmitter30 和 ConeEmitter45。
选择其中一个粒子实体。在 Property Grid 中,导航到其源粒子系统并展开发射器。

此发射器中有四个自定义元素:
自定义生成器与按秒生成的生成器类似,但它每次循环时还会发出一波粒子。
自定义初始化器最初将粒子放置成圆锥形状,并相应地设置它们的速度。
自定义更新器作用于一个名为 RactangleXY 的新粒子字段,使形状构建器在构建公告板时可以使用非均匀的大小。
自定义形状构建器与公告板类似,但有两点增强。它可以创建非均匀的矩形(而不是标准的正方形),并且它可以将矩形的 Y 轴对齐(固定)到世界的 Y 轴而不是摄像机空间。
生成器
我们将创建一个生成器,它按秒发射粒子,并且每隔几秒爆发一次。我们可以通过添加两个不同的生成器来实现这一点,但在这个示例中我们将它们合并。
[DataContract("CustomParticleSpawner")] // 用于序列化,一个好的做法是让数据契约与类同名
[Display("CustomParticleSpawner")]
public sealed class CustomParticleSpawner : ParticleSpawner
{
[DataMemberIgnore]
private float carryOver; // 私有成员不会出现在 Property Grid 上
[DataMember(100)] // 数据序列化时,此属性决定其优先级
[Display("Number of particles")] // 这是将显示在 Property Grid 上的名称
public float SpawnCount { get; set; }
[DataMemberIgnore]
private float burstTimer; // 私有成员不会出现在 Property Grid 上
[DataMember(200)] // 数据序列化时,此属性决定其优先级
[Display("Burst particles")] // 这是将显示在 Property Grid 上的名称
public float BurstCount {get;set;}
...
public override int GetMaxParticlesPerSecond()
{
return (int)Math.Ceiling(SpawnCount) + (int)Math.Ceiling(BurstCount);
}
public override void SpawnNew(float dt, ParticleEmitter emitter)
{
// 状态由基类处理。通常你只希望在活动状态下生成粒子
var spawnerState = GetUpdatedState(dt, emitter);
if (spawnerState != SpawnerState.Active)
return;
// 计算每秒粒子数
var toSpawn = spawnCount * dt + carryOver;
var integerPart = (int)Math.Floor(toSpawn);
carryOver = toSpawn - integerPart;
// 计算爆发粒子数
burstTimer -= dt;
if (burstTimer < 0)
{
burstTimer += 1f;
integerPart += (int)Math.Floor(BurstCount);
}
// 最后,告诉发射器我们这一帧想要生成多少新粒子
emitter.EmitParticles(integerPart);
}
}
这个类模拟了 @'Stride.Particles.Spawners.ParticleSpawner',额外添加了 BurstCount 和 burstTimer 来控制爆发生成的频率和数量。
SpawnNew 方法每帧被调用一次,让生成器根据经过的时间计算应该在发射器中发射多少新粒子。
作为练习,试着实现以下更改:
- 创建一个属性,让用户控制爆发时间,而不是固定的一秒爆发。
- 移除按秒生成的字段,使其成为纯粹的爆发生成器。
我们的生成器只发射粒子,但不设置任何字段。这是由初始化器完成的。
初始化器
我们希望把粒子放置成圆锥形状,并在它们生成时向外射出。
[DataContract("CustomParticleInitializer")]
[Display("Cone Initializer")]
public class CustomParticleInitializer : ParticleInitializer
{
[DataMember(100)]
[DataMemberRange(0, 120, 0.01, 0.1)]
[Display("Arc")]
public float Angle = 20f;
[DataMember(200)]
[Display("Velocity")]
public float Strength = 1f;
public CustomParticleInitializer()
{
RequiredFields.Add(ParticleFields.Position);
RequiredFields.Add(ParticleFields.Velocity);
RequiredFields.Add(ParticleFields.RandomSeed);
}
public unsafe override void Initialize(ParticlePool pool, int startIdx, int endIdx, int maxCapacity)
{
...
}
}
我们的初始化器只是为圆锥定义一个角度,为速度定义一个强度。圆锥的任何缩放和旋转都来自位置继承和偏移,这些对所有初始化器和更新器都是通用的,可以直接使用。更多信息请参见 @'Stride.Particles.Initializers.ParticleInitializer'。
初始化器的构造函数很重要,因为它设置了我们将使用的必需字段列表。初始化器设置粒子的位置和速度,所以我们添加这些字段,并且需要生成一些随机性,所以我们也添加要使用的随机种子。所有粒子在生成时都有 Life 和 RandomSeed 字段。
// 当初始化器被添加到发射器后,这个方法会对所有新粒子调用一次。我们不会更新所有粒子,而是获得一个起始索引和结束索引,必须只使用定义范围内的粒子。
public unsafe override void Initialize(ParticlePool pool, int startIdx, int endIdx, int maxCapacity)
{
// 确保字段存在,避免非法内存访问
if (!pool.FieldExists(ParticleFields.Position) || !pool.FieldExists(ParticleFields.Velocity) || !pool.FieldExists(ParticleFields.RandomSeed))
return;
var posField = pool.GetField(ParticleFields.Position);
var velField = pool.GetField(ParticleFields.Velocity);
var rndField = pool.GetField(ParticleFields.RandomSeed);
var range = (float) (Angle*Math.PI/180f);
var magnitude = WorldScale.X;
var i = startIdx;
while (i != endIdx)
{
var particle = pool.FromIndex(i);
var randSeed = particle.Get(rndField);
var x = (randSeed.GetFloat(RandomOffset.Offset2A + SeedOffset) - 0.5f)*range;
var z = (randSeed.GetFloat(RandomOffset.Offset2B + SeedOffset) - 0.5f) * range;
var u = (randSeed.GetFloat(RandomOffset.Offset2A + SeedOffset) - 0.5f) * range;
var v = (randSeed.GetFloat(RandomOffset.Offset2B + SeedOffset) - 0.5f) * Math.PI;
var xz = (float) Math.Sin(u);
var particleRandPos = new Vector3((float) Math.Cos(v) * xz, (float)Math.Sqrt(1 - u*u), (float)Math.Sin(v) * xz);
particleRandPos.Normalize();
particleRandPos *= magnitude;
WorldRotation.Rotate(ref particleRandPos); // WorldRotation 是我们初始化器的当前旋转。我们可以直接使用它,因为继承和偏移已经被考虑在内。
(*((Vector3*) particle[posField])) = particleRandPos + WorldPosition; // WorldPosition 是我们初始化器的当前位置。我们可以直接使用它,因为继承和偏移已经被考虑在内。
(*((Vector3*) particle[velField])) = particleRandPos * Strength;
i = (i + 1) % maxCapacity;
}
}
更新器
我们希望更新器基于粒子存活时间上的简单正弦函数,每帧改变粒子的宽度和高度。
由于还没有这样的字段,首先创建一个新的粒子字段。我们把它命名为 RactangleXY:
public static class CustomParticleFields
{
public static readonly ParticleFieldDescription<Vector2> RectangleXY = new ParticleFieldDescription<Vector2>("RectangleXY", new Vector2(1, 1));
}
该字段的类型为 @'Stride.Core.Mathematics.Vector2',因为我们只需要宽度和高度两个值。不会自动向粒子添加字段,所以即使你有很多声明,粒子大小也不会改变。只有当我们插入需要它们的模块(如下方的自定义更新器)时,字段才会被添加。
API 参考请参见 @'Stride.Particles.Modules.ParticleUpdater'。
[DataContract("CustomParticleUpdater")] // 用于序列化,使我们的自定义对象可以被保存。一个好的做法是让数据契约与类名相同。
[Display("CustomUpdater")] // 除非指定显示名称,否则将使用数据契约的名称。有时我们想隐藏它并显示更简单的内容。
public class CustomParticleUpdater : ParticleUpdater
{
[DataMemberIgnore] // 公共字段和属性会被序列化。我们希望在某些情况下避免这种情况,可以使用 DataMemberIgnore 属性。
public override bool IsPostUpdater => true; // 通过使此更新器成为后置更新器,我们可以确保它会对新生成的和旧的粒子(1 帧或更老)都被调用
[DataMember(10)] // 这个公共字段将被序列化。通过 DataMember 属性,我们可以指定序列化和显示顺序。
public AnimatedCurveEnum Curve; // AnimatedCurveEnum 请参见实际的示例代码
// 在构造函数中,我们必须指定此更新器所需的所有字段。
// 它使用粒子的存活时间来计算我们新创建的字段,所以我们需要 "RectangleXY" 和 "Life"
public CustomParticleUpdater()
{
// 这将作为我们的"输入"字段
RequiredFields.Add(ParticleFields.Life);
// 这是我们想要更新的字段
// 它不是基本字段的一部分——我们只为这个更新器创建了它
RequiredFields.Add(CustomParticleFields.RectangleXY);
}
// 更新方法每帧被调用一次,要求更新器遍历池中的所有粒子并更新它们的字段。
// 如果更新器是后置更新器,它会在本帧生成新粒子**之后**被调用,可能会在同一帧覆盖它们的初始值
// 如果更新器不是后置更新器,它会在本帧生成新粒子**之前**被调用,无法覆盖它们第一帧的初始值
public override void Update(float dt, ParticlePool pool)
{
...
}
}
让我们看看 Update 方法。示例代码更长,但为了简单起见,这里我们进行了精简。
public override void Update(float dt, ParticlePool pool)
{
// 确保字段存在,避免非法内存访问
if (!pool.FieldExists(ParticleFields.Life) || !pool.FieldExists(CustomParticleFields.RectangleXY))
return;
var lifeField = pool.GetField(ParticleFields.Life);
var rectangleField = pool.GetField(CustomParticleFields.RectangleXY);
// X 和 Y 边长取决于 sin(time) 和 cos(time)
foreach (var particle in pool)
{
// 获取粒子的剩余存活时间。它已经在 0 和 1 之间归一化了
var lifePi = particle.Get(lifeField) * MathUtil.Pi;
// 将矩形设置为时间的简单函数
particle.Set(rectangleField, new Vector2((float)Math.Sin(lifePi), (float)Math.Cos(lifePi)));
}
}
更新器将用简单的正弦和余弦函数,根据所有粒子的存活时间对其 RectangleXY 字段进行动画处理。
在下一步中,我们将演示如何显示创建的值。
形状构建器
shape builder 是接收所有粒子字段并创建我们要渲染的实际形状的类。它有点长,让我们分解来看。
public override int QuadsPerParticle { get; protected set; } = 1;
引擎使用 1 个四边形 = 4 个顶点 = 6 个索引来绘制四边形,但我们只能指定所需四边形的数量。对于矩形我们只需要一个。
Note
四边形的数量很重要,因为顶点缓冲区是在写出顶点数据之前分配和映射的。如果我们分配较小的缓冲区,可能导致非法内存访问和数据损坏。
public unsafe override int BuildVertexBuffer(ParticleVertexBuilder vtxBuilder, Vector3 inverseViewX, Vector3 inverseViewY,
ref Vector3 spaceTranslation, ref Quaternion spaceRotation, float spaceScale, ParticleSorter sorter)
当需要我们的形状构建器遍历所有粒子并构建形状时,会调用此方法。@'Stride.Particles.VertexLayouts.ParticleVertexBuilder' 是我们顶点流的包装器。我们将用它来写出粒子的顶点数据。
inverseViewX 和 inverseViewY 是摄像机空间中的单位向量,如果需要生成面向摄像机的形状,它们会被传递给形状构建器。
foreach (var particle in sorter)
{
var centralPos = particle.Get(positionField);
var particleSize = sizeField.IsValid() ? particle.Get(sizeField) : 1f;
var rectangleSize = rectangleField.IsValid() ? particle.Get(rectangleField) : new Vector2(1, 1);
var unitX = invViewX * (particleSize * 0.5f) * rectangleSize.X;
var unitY = invViewY * (particleSize * 0.5f) * rectangleSize.Y;
// 粒子旋转。正值表示顺时针旋转。
if (hasAngle) { ... }
var particlePos = centralPos - unitX + unitY;
var uvCoord = new Vector2(0, 0);
// 0f 0f
vtxBuilder.SetAttribute(posAttribute, (IntPtr)(&particlePos));
vtxBuilder.SetAttribute(texAttribute, (IntPtr)(&uvCoord));
vtxBuilder.NextVertex();
// 1f 0f
particlePos += unitX * 2;
uvCoord.X = 1;
vtxBuilder.SetAttribute(posAttribute, (IntPtr)(&particlePos));
vtxBuilder.SetAttribute(texAttribute, (IntPtr)(&uvCoord));
vtxBuilder.NextVertex();
// 1f 1f
particlePos -= unitY * 2;
uvCoord.Y = 1;
vtxBuilder.SetAttribute(posAttribute, (IntPtr)(&particlePos));
vtxBuilder.SetAttribute(texAttribute, (IntPtr)(&uvCoord));
vtxBuilder.NextVertex();
// 0f 1f
particlePos -= unitX * 2;
uvCoord.X = 0;
vtxBuilder.SetAttribute(posAttribute, (IntPtr)(&particlePos));
vtxBuilder.SetAttribute(texAttribute, (IntPtr)(&uvCoord));
vtxBuilder.NextVertex();
renderedParticles++;
}
我们粒子的宽度和高度既取决于均匀大小字段 Size,也取决于我们在此教程中先前创建的 RectangleXY 字段。由此,我们需要设置四边形四个角顶点的位置和纹理坐标。我们需要设置的顶点数是每个粒子所需四边形数量的四倍。
如果你的游戏需要,可以在这里添加更复杂的形状或属性。
总结
通过这 4 个自定义模块,你可以为粒子引擎添加大量功能,并根据需要定制行为。因为它们都会在 Game Studio 中被序列化和加载,一旦创建了它们,你就可以直接在 Game Studio 中与核心模块一起使用它们。
如果你想试验这些模块,试着将一个新的 .cs 文件添加到 CustomParticles.Game 项目中。你可以复制一个现有的类,但不要忘记更改类名和数据契约以避免冲突。
然后你可以在 Game Studio 中重新加载脚本。如果它们没有加载,请重新启动项目。如果你的代码中没有编译错误,你应该能在生成器、初始化器、更新器和形状构建器列表中看到新模块。