Hello!
Just making an announcement that I just open sourced my hobby project easyess!
Available at https://github.com/EriKWDev/easyess
Easyess is an implementation of an Entity Component System in Nim with macros to generate many of the tedious parts of using an ECS such as procs to add and remove components, query entities with a set of components and internal component storage of type array[N, <Component>] for each registered Component. Systems can be grouped and/or run individually.
I quickly want to give a shoutout to Polymorph (https://github.com/rlipsc/polymorph) which inspired me to learn more about macros in Nim and Entity Component Systems as a whole. Polymorph is most likely more well-suited for serious project and easyess is not trying to compete with it. Easyess is just an experiment for myself and I am using it as well as polymorph in my personal projects :)
Will add more examples using graphics in the future!
Here is a quick example (checkout examples and tests folder on github for more )
import easyess
comp:
type
Position = object
x: float
y: float
Velocity = object
dx: float
dy: float
ComponentFlag = bool
EnumComponent = enum
ecOne
ecTwo
sys [Position, vel: Velocity], "systems":
func moveSystem(item: Item) =
let
(ecs, entity) = item
oldPosition = position
position.y += vel.dy
item.position.x += item.velocity.dx
when not defined(release):
debugEcho "Moved " & ecs.inspect(entity) & " from ", oldPosition, " to ", position
createECS(ECSConfig(maxEntities: 100))
when isMainModule:
let
world = newECS()
entity1 = world.newEntity("Entity 1")
item1 = (world, entity1)
item1.addComponent(Position(x: 0.0, y: 0.0))
item1.addVelocity(Velocity(dx: -10.0, dy: 10.0))
item1.addComponent(enumComponent = ecOne)
discard world.createEntity("Entity 2"): (
Position(x: 0.0, y: 0.0),
Velocity(dx: 10.0, dy: -10.0),
[EnumComponent]ecOne
)
for i in 1 .. 10:
world.runSystems() # From the sys group above "systems" (world.run<SystemGroup>())
Thank you!
The data structures being used is simply arrays of Components. One can look at the generated types and procs by passing -d:ecsDebugMacros to the compiler. From that, we see that the "world" type from the example above looks like:
type
ECS* = ref object
nextID*: Entity
highestID*: Entity
when not defined(release):
usedLabels: Table[string, bool]
ecsInspectLabelContainer: array[100, string]
signatureContainer*: array[100, set[ComponentKind]]
positionContainer*: array[100, Position]
velocityContainer*: array[100, Velocity]
componentFlagContainer*: array[100, ComponentFlag]
enumComponentContainer*: array[100, EnumComponent]
Entities are simply IDs which means that its components are stored at its index in every container.
Every registered component gets an enum, and every entity has a "signature" which is a set of those component enums, so querying is a simple check on wether the query is a subset of an entity's signature.
Removing a component is therefore as simple as just removing that component enum from the entity's signature. Here is the generated proc to remove Position:
func removePosition*(item: (ECS, Entity)) =
## Remove `Position <easyess.html#Position>`_ from `entity` and update its signature by excluding `ckPosition <easyess.html#ComponentKind>`_
item[0].signatureContainer[item[1].idx].excl(ckPosition)
and adding a Position component looks like this:
func addPosition*(item: (ECS, Entity); position: Position) =
## Add `Position <easyess.html#Position>`_ to `entity` and update its signature by including `ckPosition <easyess.html#ComponentKind>`_
let (ecs, entity) = item
ecs.positionContainer[entity.idx] = position
ecs.signatureContainer[entity.idx].incl(ckPosition)
When entities are created I keep track on the current highest ID in the system so that any iteration over every entity only has to consider IDS from 0 up to the highest generated ID.
The most "expensive algorithm" I suppose is removing an entity and calculating what the next highest ID would become since entities with a lower ID could have previously been removed., but it really shouldn't be too bad unless we're talking about a lot of entities being added and removed constantly.
I really hope to do some more benchmarks in the future, but the performance has been more than enough for me!
This is really cool, thanks for sharing!
Will dive deeper into it later after work, but how do you handle Scene management? In my current project I opted to keep separate component arrays for each scene, so that entire scenes can be easily cleared and free up memory, but also so that all components for a given scene are arranged next to each other.
Scenes/Worlds can be created using newECS() which creates an instance of the ECS type that (in the case of the example above) looks like this:
type
ECS* = ref object
nextID*: Entity
highestID*: Entity
when not defined(release):
usedLabels: Table[string, bool]
ecsInspectLabelContainer: array[100, string]
signatureContainer*: array[100, set[ComponentKind]]
positionContainer*: array[100, Position]
velocityContainer*: array[100, Velocity]
componentFlagContainer*: array[100, ComponentFlag]
enumComponentContainer*: array[100, EnumComponent]
So the way I manage scenes is just by assigning var scene1 = newECS() and then adding all entities to scene1. When I'm done with it and want to switch to a new scene, scene1 can be cleared by removing all entities, reassigned using scene1 = newECS() , or we can instantiate a new scene again and start calling its systems.
I plan to add a nicer way to transition between scenes in the future, but currently the switch can be a bit abrupt without manually adding some kind of fading system..
Hi @EriKWDev, Polymorph author here - thanks for the shout-out. I really should post a proper announcement on here too, somewhen.
Firstly, nice work! Always good to see more ECS libraries, and especially ones based on macros :)
If you want some extra performance, would it be possible to cache query results by system? Since createEcs is building the component add/remove functions, it could infer which systems are affected and insert code to set a system's dirty flag, or something like that. If systems can avoid parsing all entities unless something that affects them changes, I bet you'll get a massive speed up.