C# 在浏览器里跑 10 万个粒子:阻力计算和位置更新约 0.1 毫秒

16 阅读4分钟

打开 EasyEcs 在线演示,选 100k,再把鼠标移到画面上。火花会被指针拉出一个个涡旋。在我这台 M4 上,批量版处理约 10 万个粒子的阻力和移动,实时读数约 0.1 毫秒;同样的工作改用逐实体 [ForEach],约 0.5 毫秒。

EasyEcs 在线演示设为 100k,鼠标把火花拉成涡旋,下方显示实时 ECS 耗时

M4 浏览器的一帧截图。目标数设为 10 万;粒子会不断过期并补充,所以实时数量会浮动。

演示中的 C# 经 Mono AOT 编译成 WebAssembly,在浏览器里使用 128 位 SIMD。读数会随浏览器负载变化。0.1 毫秒只算阻力和移动;页面另有“所有系统更新”的数字,还包含寿命、生成、鼠标吸引、碰撞和淡出。两项计时都不包括准备渲染数据和 GPU 绘制。Demo.Step 的源码能看到计时从哪里开始、到哪里结束。

同一段移动逻辑,两种 [ForEach] 写法

先看最直白的逐实体版本。生成的循环每遇到一个火花,就调用一次 Move:

[ForEach]
static void Move(ref Position p, in Velocity v, in Clock clock)
{
    p.X += v.X * clock.Dt;
    p.Y += v.Y * clock.Dt;
}

批量版本拿到位置和速度两列连续的 Span。每个 Vector128<float> 装得下两个火花的 X、Y;末尾如果剩一个,就单独处理:

[ForEach]
static void Move(
    Span<Position> positions,
    ReadOnlySpan<Velocity> velocities,
    in Clock clock)
{
    var p = MemoryMarshal.Cast<Position, Vector128<float>>(positions);
    var v = MemoryMarshal.Cast<Velocity, Vector128<float>>(velocities);
    var dt = Vector128.Create(clock.Dt);
    for (int i = 0; i < p.Length; i++)
        p[i] += v[i] * dt;

    if ((positions.Length & 1) != 0)
    {
        ref var last = ref positions[^1];
        last.X += velocities[^1].X * clock.Dt;
        last.Y += velocities[^1].Y * clock.Dt;
    }
}
对比项做的事本次 M4 浏览器读数
逐实体 [ForEach]阻力+移动;每次处理一个火花约 0.5 ms
批量 Span + SIMD同样的阻力+移动;每个向量处理两个火花约 0.1 ms

代码片段只展示移动部分;完整的逐实体移动、批量移动、逐实体阻力和批量阻力都在仓库。也可以在演示页面切换 1k、10k、50k、100k,自己看两种写法的读数如何变化。

ECS 的优势从哪来?

ECS 把状态组织成三部分:实体表示身份,组件保存位置、生命值等数据,系统处理拥有所需组件的实体。相同类型的数据紧挨着存放,系统就能顺着内存遍历,不必在一堆对象之间跳来跳去。

但数据排得整齐,只解决了遍历问题。真实的仿真或实时后端还要生成和销毁实体、加减状态、查索引、保存或同步数据。每轮都分配内存可能带来 GC 停顿;访问不连续则浪费缓存。EasyEcs 也针对这些操作做了优化。

EasyEcs 怎样提供连续的组件数据

组件是普通 C# struct,放在列按 64 字节边界对齐的 archetype 表里。源码生成器把 [ForEach] 方法变成可内联的 struct job,省去每个实体一次的委托调用。在线 Demo 的批量版本从这些列拿到 Span,用 Vector128<float> 一次处理两个火花;逐实体版本完成相同的计算。

Demo 之外,EasyEcs 允许在遍历过程中立即增删组件;组件被移除后再加回来时,可以只切换存在位,省掉一次实体迁移。库里还有变更追踪、索引、关系、快照,以及检查读写冲突的并行系统。预热后,更新、查询和结构变更的目标是 0 B 分配,单元测试有对应的分配预算检查;新建表或容量不足需要扩容时仍会分配。具体设计与测量见为什么快。

浏览器的 0.1 ms 和服务端性能怎么理解?

浏览器里的 Mono AOT/WebAssembly 使用 128 位 SIMD,这个演示也只跑一个线程。原生服务端用 CoreCLR/RyuJIT,可以使用更宽的向量和多线程;但浏览器的 0.1 毫秒不能换算成服务端的一轮更新,两边测的工作不同。

原生基准测的是另一件事。在 4 核 2.1 GHz Xeon 虚拟机、.NET 10.0.13 上,一个 World 装着 10 万个实体,跑完 9 个系统耗时 1.72 毫秒,预热后分配 0 B。拆成四个独立 World、每个 2.5 万个实体、各用一个线程时,四个全部完成共 0.92 毫秒,仍为 0 B;这不是单个 10 万实体 World 的耗时。

跨库对比中的存档读回与批量状态切换,分别有约 30× 和 15× 的单项优势。完整一轮更新的差距要小得多:EasyEcs 1.68 毫秒、Friflo 1.83 毫秒,两者都分配 0 B。这是另一组测试,不能与上面的 1.72 毫秒当成同一次结果。性能指南和完整对比公开了测试环境、方法和落后的项目。

EasyEcs 面向 .NET 10 的游戏服务端、仿真及其他长期运行的实时后端。它提供 ECS World,不提供网络传输,也不支持 Unity。想试,可以从 dotnet add package EasyEcs、入门教程或上面的在线演示开始。有基准没覆盖的负载,欢迎到仓库提 issue;觉得有用,也欢迎点个 Star。