DEV Community

Cover image for 100k particles in C# WebAssembly: ~0.1 ms for motion on an M4
Jason Xu
Jason Xu

Posted on AI-assisted

100k particles in C# WebAssembly: ~0.1 ms for motion on an M4

Set the EasyEcs live demo to 100k and move your pointer across the sparks. They bend into a vortex. On the M4 I used, the batched drag and movement systems take about 0.1 ms for around 100,000 live entities. The same work as per-entity [ForEach] methods takes about 0.5 ms.

The EasyEcs browser demo at its 100k setting, with the pointer pulling sparks into a vortex and live ECS timings below

One live M4 browser frame. The selected target is 100k; the live count changes as sparks expire and are replaced.

This is C# compiled with Mono AOT to WebAssembly, using 128-bit SIMD. The reading varies from frame to frame, and the ~0.1 ms covers only drag and movement. The separate “all systems” number adds lifetime, spawning, pointer attraction, bounces and fading. Neither figure includes copying the frame array or drawing it. The timers in Demo.Step mark the boundary:

long start = Stopwatch.GetTimestamp();
if (batched) world.Update<Batched>();
else world.Update<PerEntity>();
long moved = Stopwatch.GetTimestamp();
world.Update(); // lifetime, spawning, attraction, bounces, fading
long end = Stopwatch.GetTimestamp();
Enter fullscreen mode Exit fullscreen mode

start → moved is the motion group. start → end adds the other ECS systems; rendering still happens later.

One job per spark, or a span of sparks

The contrast is easier to see in code. Both are [ForEach] jobs from the live demo. In the first, the generated loop calls Move for each spark:

[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;
}
Enter fullscreen mode Exit fullscreen mode

The batch version receives matching runs of position and velocity columns as spans. Each Vector128<float> contains the X and Y values of two sparks; the final scalar branch handles an odd-sized run:

[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;
    }
}
Enter fullscreen mode Exit fullscreen mode
Motion group in the live demo Work M4 browser reading during this check
Per-entity [ForEach] Drag and movement, one spark per call around 0.5 ms
Batch Span + SIMD The same drag and movement, two sparks per vector around 0.1 ms

The table times drag and movement together; the excerpts show only movement. The per-entity movement, batch movement and both drag versions are in the repo. You can switch between 1k, 10k, 50k and 100k on the demo page and watch the numbers change.

Why the data layout matters

In an ECS, an entity is an identity, components are data such as position or health, and systems loop over entities that have the components they need. Similar data sits together in memory, which suits tight loops.

Layout alone does not make a program fast. A simulation or real-time backend also creates and destroys entities, expires buffs, looks up values, and saves or replicates state. Allocation in every tick invites GC pauses; scattered reads waste cache. EasyEcs is built around those operations as well as iteration.

How EasyEcs gets those spans

EasyEcs stores C# structs in archetype tables with 64-byte-aligned columns. A source generator turns [ForEach] methods into struct jobs that the JIT can inline. The batch method gets spans over the columns; the per-entity version does the same arithmetic one spark at a time.

Outside the demo, EasyEcs applies structural changes immediately, even during a pass. If a component is removed and later restored, a presence bit can avoid moving the entity again. The library also has change tracking, indexes, relations, snapshots and parallel systems with checked read/write sets. Its warmed-up hot paths target 0 B allocated, with unit tests for that budget; creating a new table or growing past capacity can still allocate. The performance design guide links the mechanisms to source and measurements.

Browser demo versus native .NET

The browser runs this demo on one thread with 128-bit SIMD. Native CoreCLR can use wider vectors and multiple threads. That gives native code more options, but the ~0.1 ms browser reading cannot be converted into a server tick: the workloads are different.

The native benchmarks answer a separate question. On a 4-core 2.1 GHz Xeon VM with .NET 10.0.13, a 100,000-entity tick across nine systems takes 1.72 ms and allocates 0 B after warm-up. Four independent worlds of 25,000 entities, one per thread, finish in 0.92 ms total, also at 0 B. Saving and loading 10,000 entities and bulk changes across 100,000 show larger leads over other libraries in their own tests. The performance guide and full comparison publish the setup, competitors and cases where EasyEcs loses.

EasyEcs targets .NET 10 and fits game servers, simulations and other long-running real-time backends. It provides the ECS world, not network transport, and does not support Unity's current runtimes. Start with dotnet add package EasyEcs, the tutorial or the demo. If your workload exposes a gap, open an issue; if the library helps, a star on the repository is welcome.

Top comments (0)