粒子系统设计问题与优化

在设计粒子系统时,需要处理大量描述粒子的数据,这要求容器不仅易于扩展,而且足够快速。本文将讨论此类容器的选择、问题和可能的解决方案。

首先,来看一个常见的粒子类实现,然后分析其中存在的问题。

C++ class Particle { public: bool m_alive; Vec4d m_pos; Vec4d m_col; float time; // ... 其他字段 public: // 构造函数... void update(float deltaTime); void render(); };

然后是使用这个类的方式:

std::vector particles; // 更新函数: for (auto &p : particles) p.update(dt); // 渲染代码: for (auto &p : particles) p.render();

对于简单的情况,这种实现方式可能是可行的。但是,让提出几个问题:

  • 否遵循了单一职责原则(SRP)?
  • 如果想给粒子添加一个字段,或者有一个粒子系统需要pos/col,另一个需要pos/col/rotations/size,结构能否支持这样的配置?
  • 如果想实现新的更新方法,是否应该在派生类中实现?
  • 代码是否高效?

答案

看起来这里违反了SRP。Particle类不仅负责持有数据,还执行更新、生成和渲染。也许更好的设计是有一个可配置的类来存储数据,其他系统/模块用于更新和渲染。

构建Particle类的方式限制了动态添加新属性的可能性。问题在于在这里使用的是AoS(数组的结构)模式,而不是SoA(结构的数组)。在SoA中,当想要添加更多的粒子属性时,只需创建/添加一个新的数组。

正如第一点提到的:违反了SRP,因此最好有一个单独的系统用于更新和渲染。对于简单的粒子系统,最初的解决方案是可行的,但当想要一些模块化/灵活性/可用性时,它就不够好了。

设计中至少有三个性能问题:

  • AoS模式可能会影响性能。
  • 在每个粒子的更新代码中,不仅有计算代码,还有一个(虚拟)函数调用。对于100个粒子,几乎看不到任何差异,但当目标是10万个或更多时,差异就会显现出来。
  • 同样的问题也适用于渲染。不能单独渲染每个粒子,需要将它们批量到顶点缓冲区,并尽可能少地进行绘制调用。

所有上述问题都必须在设计阶段解决。

添加/删除粒子

上述代码中没有明显的问题,但粒子系统的另一个重要话题是添加和杀死粒子的算法:

void kill(particleID) { ?? } void wake(particleID) { ?? }

如何高效地做到这一点?

看起来粒子需要一个动态数据结构——想要动态地添加和删除粒子。当然,可以使用列表或std::vector并在每次更改时进行更改,但那样会有效率吗?经常重新分配内存(每次创建粒子时)是好事吗?

可以最初假设的是,可以分配一个巨大的缓冲区,它将包含最大数量的粒子。这样,就不需要经常重新分配内存了。

解决了一个问题:经常缓冲区重新分配,但另一方面,现在面临着碎片化的问题。一些粒子是活的,一些不是。那么如何在一个单一的缓冲区中管理它们呢?

至少可以用两种方式管理缓冲区:

  • 使用alive标志,并在for循环中只更新/渲染活跃的粒子。不幸的是,这会导致另一个渲染问题,因为需要有一个连续的缓冲区来渲染。不能轻易地检查一个粒子是否活着。为了解决这个问题,可以,例如,创建另一个缓冲区,并在每次渲染之前将活跃的粒子复制到其中。
  • 动态地将杀死的粒子移动到末尾,以便缓冲区的前面只包含活跃的粒子。

正如上面图片所示,当决定一个粒子需要被杀死时,将其与最后一个活跃的粒子交换。

这种方法比第一个想法更快:

  • 当更新粒子时,不需要检查它是否活着。只更新缓冲区的前面。
  • 不需要将只活跃的粒子复制到另一个缓冲区。
沪ICP备2024098111号-1
上海秋旦网络科技中心:上海市奉贤区金大公路8218号1幢 联系电话:17898875485