This project is lightweight ECS implementation with archetypal storage, heavily inspired by FLECS. The API aims to be very fast and allocation free for queries and component updates.
At the moment the library is not distributed on nuget so you still have to pull it from git and reference it in your project.
The api is very similar to bevy-ecs but the naming conventions is inspired from Stride3D's.
Everything starts with the creation of a World
object. Worlds manage their entities, storages for components and processors. You usually use it through the App
object.
var app = new App();
Once you have a world you can spawn entities either with components or without.
app.World.Commands.Spawn();
app.World.Commands.Spawn(new Name("John Doe"), default(Transform), (1,"some text"));
app.World.Commands
.Spawn()
.With(Name("Jane doe"))
.With(("mochi",5,true));
Components are stored in List<T>
. To make sure components are not allocated individually on the heap, they are constrained to be structs.
This both avoids fragmentation and make sure iterating over them is made very fast.
Using the code above we could have defined our components this way :
public record struct Name(string Value)
{
public static implicit operator string(Name n) => n.Value
}
public record struct Transform(Vector3 Position, Quaternion Rotation, Vector3 Scale);
In this library, the S in ECS has been renamed to Processor
, this was a choice influenced by Stride's naming convention and also because I personally feel System is very vague for what this implementation is really.
To create Systems/Processors you can either create it from a class implementation
public class MyStartupProcessor : Processor<Commands, Resource<MyResource>>
{
public override void Update()
{
var commands = Query1;
commands.Spawn(new NameComponent("Jane doe"), 5, new HealthComponent(100,100));
commands.Spawn();
}
}
public class MyFirstProcessor : Processor<Query<Name,Transform, NoFilter>>
{
public override void Update()
{
foreach(var e in Query)
{
// Here goes your logic
}
}
}
Or create a static function
public static void MyFirstSystem(Query<Name,Transform, NoFilter> entities)
{
// Here goes your logic
}
And then add it to the world and you can start updating your frames!
app.AddProcessor<MyFirstProcessor>();
app.AddStartupProcessor<MyStartupProcessor>();
// world.AddProcessor(new MyFirstProcessor());
app.AddProcessor(
(Query<Name,Transform, NoFilter> q1) => MyFirstSystem(q1)
);
for(int i = 0; i < 100; i++)
world.Update();
This is the meat and potatoes of this library. Iterating over entities can be done in many different ways but for that there needs to be an explanation about how component storage works in this library.
Entities are just indices linked to Archetype
s. They are stored in the world as a list.
Entities are grouped together based on which group components they hold. Those groups are called Archetype
s.
When you spawn an entity with components, the world checks if this entity can fit in an existing Archetype
or has to generate a new one.
You can also add a component to an entity and the world will wait for the end of a frame to move the entity to another Archetype
and add the corresponding component data.
Archetype
s contain a Storage
value which has a type ressembling Dictionary<Type,List<T>>
and an index redirection to tell which Archetype
index corresponds to which entity index.
As a bare bone implementation, you could iterate over those Archetype
s yourself and select which entities you want to work with like so :
public class MyFirstProcessor : Processor<Query<Name,Transform, NoFilter>>
{
public override void Update()
{
// Processors created with a class have access to the world, so you can access pretty much any storage from there
World.Archetypes.Values[0].Storage[typeof(Name)][0] = new Name("Ada Lovelace");
}
}
This is a very versatile way of querying the world, you get to the data you need, but it's mouthful and not really good for performances.
Processors have Query fields that contains helper methods and iterators to help you iterate over entities and their components. When using iterators you constrain your logic to the types you have chosen to work with. This makes it easier for the system to avoid processors accessing the same chunks of memory at the same time, to avoid cache misses.
// Here the processor queries over entities that have a Name and Transform components
public class MyFirstProcessor : Processor<Query<Name,Transform, NoFilter>>
{
public override void Update()
{
// Query is a field helping you with iterators
// The 1 is because you can have up to 4 queries in a processor if you want to iterate over two different list of entities
// e.g. an entity with a mesh component and another with a camera component
foreach(var entity in Query)
{
// The entity here can be deconstructed into the components queried
var (name, transform) = entity;
// entity also has a method to set a component. It can be one you queried, or another that you know exists but haven't queried
// There also is a Get<T> method as well as a Has<T> method to help you make safe code
if(name == "John Doe")
entity.Set(new Name("Ada Lovelace"));
}
}
}
The implementation offers a processor scheduler. Processors can be declared in ordered stages, you can execute logic for input events before the game logic by creating stages and ordering them.
Processors with disjoint queries (i.e. that queries completely different components) are grouped together in stages in order to be run in parallel.
var app =
new App()
// This startup processor will be run during the `Startup` stage
.AddStartupProcessor<StartupProcessor>()
// These processors will be run during the default `Main` stage, you can specify the stage by adding the optional string parameter name
.AddProcessor<SayHello>()
.AddProcessor<WriteAge>();
// Upon update, SayHello and WriteAge will run in parallel since they both query different types in the database.
app.Update();
Given these processors :
public class StartupProcessor : Processor<Resource<WorldCommands>>
{
public StartupProcessor() : base(null!)
{
}
public override void Update()
{
Random rand = new Random();
WorldCommands commands = Query;
for(int i = 0; i < 1000; i++)
{
commands.Spawn(rand.Next(1,100), new NameComponent($"john n°{i}"));
}
}
}
public class WriteAge : Processor<Query<Read<int, NoFilter>>>
{
public WriteAge() : base(null!) { }
public override void Update()
{
foreach(var entity in Query)
{
Console.WriteLine($"There's a person that is {entity.Get<int>()} years old");
}
}
}
public class SayHello : Processor<Query<NameComponent, NoFilter>>
{
public SayHello() : base(null!) { }
public override void Update()
{
foreach (var entity in Query)
{
Console.WriteLine($"Hello {entity.Get<NameComponent>().Name}!");
}
}
}
The F# api covers a subset of the C# api but it is designed to be very friendly to functional programming.
Here's an example :
open SoftTouch.ECS
open SoftTouch.ECS.FSharp
open SoftTouch.ECS.Querying
[<Struct>]
type NameComponent =
val mutable Name : string
new (n : string) = {Name = n}
override this.ToString() = $"{this.Name}"
let app = new App()
let startup (commands : Commands) =
commands
|> Commands.spawn
|> Commands.WithValue (NameComponent "No Name")
|> ignore
let nameSystem (entities : Query<NameComponent, NoFilter>) : unit =
for entity in entities do
entity.Get<NameComponent>().Name
|> printfn "original name is : %s"
let name = NameComponent "Kujo Jolyne"
entity.Set(&name)
entity.Get<NameComponent>()
|> printfn "Changed to %A"
let x = 0;
app
|> Processor.AddStartup startup
|> Processor.Add nameSystem
|> App.update
|> App.update
|> (fun app -> app.World)
|> World.getEntity 0
|> Entity.Get<NameComponent>
|> fun x -> x.Name
|> printfn "Hello %s"