MyECS
Table of Contents
- Introduction
- Main parts of ecs
- Special components
- Other classes
- Engine integration
- Other features
- Benchmarks
- Plans
- Contributors
Introduction
You can add shard as a dependency to your application's shard.yml
:
dependencies:
myecs:
github: konovod/myecs
Alternativale, you can just copy file src/myecs.cr
to your sources as it's single-file library.
Then do
require "myecs"
And then use it:
# declare components
record Position < ECS::Component, x : Int32, y : Int32
record Velocity < ECS::Component, vx : Int32, vy : Int32
# declare systems
class UpdatePositionSystem < ECS::System
def filter(world)
world.all_of([Position, Velocity])
end
def process(entity)
pos = entity.getPosition
speed = entity.getVelocity
entity.update(Position.new(pos.x + speed.x, pos.y + speed.y))
end
end
# create world
world = ECS::World.new
# create entities
5.times { world.new_entity.add(Position.new(10, 10)) }
10.times do
ent = world.new_entity
ent.add(Position.new(1, 1))
ent.add(Velocity.new(1, 1))
end
# create systems
systems = ECS::Systems.new(world)
systems.add(UpdatePositionSystem)
# run systems
systems.init
10.times do
systems.execute
end
systems.teardown
Main parts of ecs
Entity
Сontainer for components. Consists from UInt64 and pointer to World
:
struct Entity
getter id : EntityID
getter world : World
...
# Creates new entity in world context.
# Basically just allocates a new identifier so it's fast.
entity = world.new_entity
# destroying entity marks entity id as free so it can be reused. It is also destroyed when last component removed from it.
# If you need to hold an empty entity, suggested way is to add some component to it.
entity.destroy
Component
Container for user data without / with small logic inside. Based on Crystal struct's:
record Comp1 < ECS::Component,
x : Int32,
y : Int32,
name : String
Components can be added, requested, removed:
entity = world.new_entity
entity.add(Comp1.new(0, 0, "name"))
# method is autogenerated from component class name.
# Will raise if component isn't present
comp1 = entity.getComp1
comp2 = entity.getComp2? # will return nil if component isn't present
entity.remove(Comp1)
# basically shortcut for deleting one component and adding another.
entity.replace(Comp1, Comp2.new)
They can be updated (changed) using several ways:
entity = world.new_entity
entity.add(Comp1.new(0, 0, "name"))
# Replace Comp1 with another instance of Comp1.
# Will raise if component isn't present
entity.update(Comp1.new(1, 1, "name1"))
entity.set(Comp1.new(2, 2, "name2")) # Adds Comp1 or replace it if already present
# autogenerated method, returns Pointer(Comp1), so you can access it directly
# this is not a recommended way to work with Crystal structs
# but can provide maximum performance
ptr = entity.getComp1_ptr
ptr.value.x = 5
# important - after deleting component in a pool would be reused
# so don't save a pointer if you are not sure that component won't be deleted
System
Сontainer for logic for processing filtered entities.
User class can implement init
, execute
, teardown
, filter
and process
(in any combination. Just skip methods you don't need).
class UserSystem < ECS::System
# @world : ECS::World - world
# @active : Bool - allows to temporary enable or disable system
def initialize(@world : ECS::World)
super(@world) # constructor should pass @world field
end
def init
# Will be called once during ECS::Systems.init call
end
def execute
# Will be called on each ECS::Systems.execute call
end
def teardown
# Will be called once during ECS::Systems.teardown call
end
def filter(world)
# Called once during ECS::Systems.init, after #init call.
# If this method present, it should return a filter that will be applied to a world
world.of(SomeComponent)
end
def process(entity)
# will be called during each ECS::Systems.execute call, before #execute,
# for each entity that match the #filter
end
end
Special components
ECS::SingleFrame
annotation @[ECS::SingleFrame]
is for components that have to live 1 frame (usually - events). The main difference is that they are supposed to be deleted at once, so their storage can be simplified (no need to track free indexes). They should be deleted by adding ECS::RemoveAllOf
system in a right place of systems list (or just using .remove_singleframe(T)
).
require "./src/myecs"
@[ECS::MultipleComponents]
@[ECS::SingleFrame]
record SomeRequest < ECS::Component, data : String
class ExecuteSomeRequestsSystem < ECS::System
def filter(world)
world.of(SomeRequest)
end
def process(ent)
req = ent.getSomeRequest
puts "request #{req.data} called for #{ent}"
end
end
world = ECS::World.new
systems = ECS::Systems.new(world)
.add(ExecuteSomeRequestsSystem)
.remove_singleframe(SomeRequest) # shortcut for `add(ECS::RemoveAllOf.new(@world, SomeRequest))`
systems.init
# now you can add SomeRequest to the entity
world.new_entity.add(SomeRequest.new("First")).add(SomeRequest.new("Second"))
systems.execute
In case you tend to forget about removing such single-frame components, ECS checks it for you - if a single-frame component is created, but no corresponding RemoveAllOf
is present in systems list, runtime exception will be raised
@[ECS::SingleFrame]
record SomeEvent < ECS::Component
world = ECS::World.new
systems = ECS::Systems.new(world)
# ...some systems added
systems.init
systens.execute #this won't raise - maybe we don't need SomeEvent at all
world.new_entity.add(SomeEvent.new) # raises
In a rare cases when you need to override this check, you can use @[ECS::SingleFrame(check: false)]
form:
@[ECS::SingleFrame(check: false)]
record SomeEvent < ECS::Component
world = ECS::World.new
systems = ECS::Systems.new(world)
# ...
world.new_entity.add(SomeEvent.new) # this won't raise
ECS::MultipleComponents
Note above example also shows the use of @[ECS::MultipleComponents]
. This is for components that can be added multiple times. They have some limitations though - filters can't iterate over several of components with this annotation (as this would usually mean cartesian product, unlikely needed in practice), there is no way to get multiple components outside of filter (it is planned though, but it won't be efficient nor cache-friendly), delete
deletes all of components on target entity and there is no way to delete only one.
ECS::MultipleComponents
can be combined with ECS::SingleFrame
but that's not a requirement - there are perfectly correct use cases for ECS::MultipleComponents
on persistent components - add several sprites to one renderable object or add several weapons to a tank. The only thing is that with current API you won't be able to remove single weapon ion that case - only remove all of them. So if you need better control over components just use good old "add an entity with single component and link it to parent entity" approach.
ECS::SingletonComponent
annotation @[ECS::SingletonComponent]
is for data sharing. It creates component that is considered present on every entity (iteration on it isn't possible though). So you can do
@[ECS::SingletonComponent]
record Config < ECS::Component, values : Hash(String, Int32)
class InitConfigSystem < ECS::System
def init
config = ...some config initializatio
@world.new_entity.add(Config.new(config))
end
end
class SomeAnotherSystem < ECS::System
def execute
config = @world.new_entity.getConfig.values # gets the same values
# another way
config = @world.getConfig.values
end
end
Other classes
ECS::World
Root level container for all entities / components, is iterated with ECS::Systems:
world = ECS::World.new
# you can delete all entities
world.delete_all
# you can create entity
world.new_entity
# you can iterate all entities
world.each_entity do |entity|
puts entity.id
end
# you can create filters
world.any_of([Comp1, Comp2]).any_of([Comp3, Comp4])
ECS::Filter
Allows to iterate over entities with specified conditions.
Created by call world.new_filter
or just by adding any conditions to world
.
Filters that is possible:
any_of([Comp1, Comp2])
: at least one of the components must be present on entityall_of([Comp1, Comp2])
: all of the components must be present on entityof(Comp1)
: alias forall_of([Comp1])
exclude([Comp1])
: none of the components could be present on entityselect{|ent| some_check(ent) }
: user-provided filter procedure, that must return true for entity to be passed.
All of them can be called 0, 1, or many times using method chaining.
So any_of([Comp1, Comp2]).any_of([Comp3, Comp4])
means that either Comp1 or Comp2 should be present AND either Comp3 or Comp4 should be present.
ECS::Systems
Group of systems to process EcsWorld
instance:
world = ECS::World.new
systems = ECS::Systems.new(world)
systems
.add(MySystem1.new(world))
.add(MySystem2) # shortcut for add(MySystem2.new(systems.@world))
.add(MySystem3)
systems.init
loop do
systems.execute
end
systems.teardown
You can add Systems to Systems to create hierarchy.
You can inherit from ECS::Systems
to add systems automatically:
class SampleSystem < ECS::Systems
def initialize(@world)
super
add InitPlayerSystem
# note that shortcut `add KeyReactSystem` isn't applicaple here because
# system require other params in initialize
add KeyReactSystem.new(@world, pressed: CONFIG_PRESSED, down: CONFIG_DOWN)
add ReactPlayerSystem
add MovePlayerSystem
add RotatePlayerSystem
add StopRotatePlayerSystem
add SyncPositionWithPhysicsSystem
add DrawDebugSystem
end
end
Engine integration
huh, this is integration with my nonoengine:
# main app:
require "./ecs"
require "./basic_systems"
require "./physics_systems"
require "./demo_systems"
@[ECS::SingleFrame(check: false)]
struct QuitEvent < ECS::Component
end
world = ECS::World.new
systems = ECS::Systems.new(world)
.add(BasicSystems)
.add(PhysicSystems)
.add(SampleSystem)
systems.init
loop do
systems.execute
break if world.component_exists? QuitEvent
end
systems.teardown
...
# basic_systems.cr:
require "./libnonoengine.cr"
require "./ecs"
class BasicSystems < ECS::Systems
def initialize(@world)
super
add EngineSystem.new(world)
# add RenderSystem.new(world)
add ShouldQuitSystem.new(world)
end
end
class EngineSystem < ECS::System
def init
Engine[Params::Antialias] = 4
Engine[Params::VSync] = 1
Engine.init "resources"
end
def execute
Engine.process
end
end
class ShouldQuitSystem < ECS::System
def execute
@world.new_entity.add(QuitEvent.new) if !Engine::Keys[Key::Quit].up?
end
end
see bench_ecs.cr
for some examples, and spec
folder for some more. Proper documentation and examples are planned, but not soon.
Other features
Statistics
You can add ECS.debug_stats
at he end of program to get information about number of different systems and component classes during compile-time. Userful mostly just for fun :)
you can get runtime statistics (how many components of each type is present) using ECS::World#stats
. It returns with component name as a key and components count as value.
There is also non-allocating version of stats
that yields an info instead of creating a hash:
world = init_benchmark_world(1000000)
puts world.stats
# {"Comp1" => 500000, "Comp2" => 333334, "Comp3" => 200000, "Comp4" => 142858, "Config" => 1}
# will print the same info
world.stats do |comp_name, value|
puts "#{comp_name}: #{value}"
end
Iterating without filter
Sometimes you just need to check if some component is present in a world. No need to create a filter for it - just use world.component_exists?(SomeComponent)
You can also iterate over single component without creating Filter using world.query
.
This could be useful when iterating inside System#process
:
class FindNearestTarget < ECS::System
def filter(world)
world.all_of([Pos, FindTarget])
end
def process(entity)
pos = entity.getPos
nearest = Nil
nearest_range = INFINITY
# world.of(IsATarget) will allocate a Filter, so you should create it at initialize and store it
# so here is an easier way:
world.query(IsATarget).each_entity do |target|
range = distance(target.getPos, pos)
if nearest_range > range
nearest = target
nearest_range = range
end
end
# ...
end
end
Callbacks
If you define when_added
method, it will be called every time after component was added to entity.
If you define when_removed
method, it will be called every time before component is removed from entity (or entity is destroyed).
record PhysicalBody < ECS::Component, raw : PhysEngine::Body do
def when_removed(entity)
raw.destroy
end
end
This correctly process SingleFrame, Multiple and Singleton components.
The only limitation is that currently world.delete_all
won't call when_removed
(this would hurt performance), so use a specialized filter (or query) to delete all components in that case.
Benchmarks
See Benchmarks
Plans
Short-term
- [x] Reuse entity identifier, allows to replace
@sparse
hash with array - [ ] generations of EntityID to catch usage of deleted entities
- [ ] better API for multiple components - iterating, array, deleting only one
- [ ] optimally delete multiple components (linked list)
- [X] bitmasks for entities. Could they improve performance? - no they don't
- [X] check that all singleframe components are deleted somewhere
- [X] benchmark comparison with flecs (https://github.com/jemc/crystal-flecs)
- [ ] groups from EnTT - could be useful?
- [ ] Serialization
Future
- [X] Callbacks on adding\deleting components
- [ ] Work with arena allocator to minimize usage of GC
Contributors
- Andrey Konovod - creator and maintainer