Table of Contents

教程:自定义粒子

中级 美术 程序员

本教程将展示如何为粒子系统创建自定义扩展,提供核心引擎中没有的功能。

如果你不熟悉编辑粒子,请参见创建粒子。

首先创建一个新的 Sample: Particles 项目。

粒子示例项目

此项目包含不同的场景,演示粒子的不同使用方式。打开 CustomParticles 场景。

场景中有三个粒子实体:ConeEmitter15、ConeEmitter30 和 ConeEmitter45。

选择其中一个粒子实体。在 Property Grid 中,导航到其源粒子系统并展开发射器。

media/particles-samples-custom-1.png

此发射器中有四个自定义元素:

  • 自定义生成器与按秒生成的生成器类似,但它每次循环时还会发出一波粒子。

  • 自定义初始化器最初将粒子放置成圆锥形状,并相应地设置它们的速度。

  • 自定义更新器作用于一个名为 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 中重新加载脚本。如果它们没有加载,请重新启动项目。如果你的代码中没有编译错误,你应该能在生成器、初始化器、更新器和形状构建器列表中看到新模块。

另请参阅