Alternative to built-in filters using lambdas for Morpeh ECS.
- Lambda syntax for querying entities & their Components
- Supporting jobs & burst
- Automatic jobs scheduling
- Jobs dependencies
- Events
- World Events
- Entity Events
public class ExampleQuerySystem : QuerySystem
{
protected override void Configure()
{
CreateQuery()
.WithAll<PlayerComponent, ViewComponent, Reference<Transform>>()
.WithNone<Dead>()
.ForEach((Entity entity, ref PlayerComponent player, ref ViewComponent viewComponent) =>
{
player.value++;
});
}
}
public class CustomSequentialJobQueriesTestSystem : QuerySystem
{
protected override void Configure()
{
var jobHandle = CreateQuery()
.With<TestComponent>()
.ScheduleJob<CustomTestJobParallel>();
CreateQuery()
.With<TestComponent>()
.ScheduleJob<CustomTestJobParallelAfterwards>(jobHandle);
}
}
Usually, the regular system in Morpeh is implemented this way:
public class NoQueriesTestSystem : UpdateSystem
{
private Filter filter;
public override void OnAwake()
{
filter = World.Filter.With<TestComponent>();
}
public override void OnUpdate(float deltaTime)
{
foreach (var entity in filter)
{
ref var testQueryComponent = ref entity.GetComponent<TestComponent>();
testQueryComponent.value++;
}
}
}
There will be 1 000 000
entities and 100
iterations of testing for this and the other examples;
Results: 14.43 seconds.
In order to optimize this, we can store a reference to the Stash<T>
that contains all the components of type TestComponent
for different entities:
public class NoQueriesUsingStashTestSystem : UpdateSystem
{
private Filter filter;
private Stash<TestComponent> stash;
public override void OnAwake()
{
filter = World.Filter.With<TestComponent>();
stash = World.GetStash<TestComponent>();
}
public override void OnUpdate(float deltaTime)
{
foreach (var entity in filter)
{
ref var testQueryComponent = ref stash.Get(entity);
testQueryComponent.value++;
}
}
}
Results: 9.05 seconds (-38%)
In order to remove the boilerplate for acquiring the components and still have it optimized using Stashes, you can use the Queries from this plugin instead:
public class WithQueriesSystem : QuerySystem
{
protected override void Configure()
{
CreateQuery()
.With<TestComponent>()
.ForEach((Entity entity, ref TestComponent testQueryComponent) =>
{
testQueryComponent.value++;
});
}
}
Results: 9.45 seconds (+5%)
As you can see, we're using a QuerySystem
abstract class that implements the queries inside, therefore we have no OnUpdate
method anymore. If you need the deltaTime
though, you can acquire it
using protected float deltaTime
field in QuerySystem
, which is updated every time QuerySystem.OnUpdate()
is called.
Performance-wise, it's a bit slower than the optimized solution that we've looked previously (because of using lambdas), but still faster that the "default" one and is much smaller than both of them.
In order to optimize it even further, one can use burst jobs. Firstly, let's create a job:
[BurstCompile]
public struct CustomTestJobParallel : IJobParallelFor
{
[ReadOnly]
public NativeFilter entities;
public NativeStash<TestComponent> testComponentStash;
public void Execute(int index)
{
var entityId = entities[index];
ref var component = ref testComponentStash.Get(entityId, out var exists);
if (exists)
{
component.value++;
}
}
}
Now we should create a system that will run the job. Let's check how it's done using Morpeh:
public class NoQueriesUsingStashJobsTestSystem : UpdateSystem
{
private Filter filter;
private Stash<TestComponent> stash;
public override void OnAwake()
{
filter = World.Filter.With<TestComponent>();
stash = World.GetStash<TestComponent>();
}
public override void OnUpdate(float deltaTime)
{
var nativeFilter = filter.AsNative();
var parallelJob = new CustomTestJobParallel
{
entities = nativeFilter,
testComponentStash = stash.AsNative()
};
var parallelJobHandle = parallelJob.Schedule(nativeFilter.length, 64);
parallelJobHandle.Complete();
}
}
Results: 1.67
seconds (-83%).
Jobs are much faster, as you can see, but it requires even more preparations. Let's remove this boilerplate by using this plugin:
public class CustomJobQueriesTestSystem : QuerySystem
{
protected override void Configure()
{
CreateQuery()
.With<TestComponent>()
.ScheduleJob<CustomTestJobParallel>();
}
}
Results: 1.69
seconds (+1%).
This approach uses Reflections API
to fill in all the required parameters in the job (NativeFilter
& NativeStash<T>
), but the code is well optimized and it affects performance very slightly.
Supports as many stashes as you want to.
You should define all the queries inside Configure
method.
CreateQuery()
returns an object of type QueryBuilder
that has many overloads for filtering that you can apply before describing the ForEach
lambda.
You can also combine multiple filtering calls in a sequence before describing the ForEach
lambda:
CreateQuery()
.WithAll<TestComponent, DamageComponent>()
.WithNone<Dead, Inactive>()
.ForEach(...)
Selects all the entities that have all of the specified components.
CreateQuery()
.WithAll<TestComponent, DamageComponent>()
.ForEach(...)
CreateQuery()
.WithAll<TestComponent, DamageComponent, PlayerComponent, ViewComponent>()
.ForEach(...)
Supports up to 8 arguments (but you can extend it if you want).
Equivalents in Morpeh:
Filter = Filter.With<TestComponent>().With<DamageComponent>();
Filter = Filter.With<TestComponent>().With<DamageComponent>().With<PlayerComponent>().With<ViewComponent>();
Selects all the entities that have none of the specified components.
CreateQuery()
.WithNone<Dead, Inactive>()
.ForEach(...)
CreateQuery()
.WithNone<Dead, Inactive, PlayerComponent, ViewComponent>()
.ForEach(...)
Supports up to 8 arguments (but you can extend it if you want).
Equivalents in Morpeh:
Filter = Filter.Without<Dead>().Without<Inactive>();
Filter = Filter.Without<Dead>().Without<Inactive>().Without<PlayerComponent>().Without<ViewComponent>();
Equivalent to Morpeh's Filter.With<T>
.
Equivalent to Morpeh's Filter.Without<T>
.
You can specify your custom filter if you want:
CreateQuery()
.WithAll<TestComponent, DamageComponent>()
.Also(filter => filter.Without<T>())
.ForEach(...)
There are multiple supported options for describing a lambda:
.ForEach<TestComponent>(ref TestComponent component)
.ForEach<TestComponent>(Entity entity, ref TestComponent component)
You can either receive the entity as the 1st parameter or you can just skip it if you only need the components.
Supported up to 8 components (you can extend it if you want)
Restrictions
- You can only receive components as ref
- You can't receive Aspects
Same as ForEach
, but utilizes System.Threading.Tasks.Parallel.ForEach
to run the query in multiple threads (same amount as user's CPU cores).
The system will wait until the ForEachParallel finishes. If you want to have async calculations for your system, please use Jobs & Burst
Instead of specifying a lambda for each entity that will be processed, you can specify lambda that will be executed once for each update:
.ForAll()
.ForAll(Filter filter)
You can also just receive the native filter & stashes if you want to do your custom logic.
[BurstCompile]
public struct CustomTestJobParallel : IJobParallelFor
{
[ReadOnly]
public NativeFilter entities;
public NativeStash<TestComponent> testComponentStash;
public void Execute(int index)
{
var entityId = entities[index];
ref var component = ref testComponentStash.Get(entityId, out var exists);
if (exists)
{
component.value++;
}
}
}
public class CustomJobsQueriesTestSystem : QuerySystem
{
protected override void Configure()
{
CreateQuery()
.With<TestComponent>()
.ForEachNative((NativeFilter entities, NativeStash<TestComponent> testComponentStash) =>
{
var parallelJob = new CustomTestJobParallel
{
entities = entities,
testComponentStash = testComponentStash
};
var parallelJobHandle = parallelJob.Schedule(entities.length, 64);
parallelJobHandle.Complete();
});
}
}
Results: ~2.40 seconds (1 000 000
entities & 100
iterations)
Supports up to 6
arguments (you can extend it if you want).
In order to start using Events, you should enable Event's feature for your world:
world = World.Create();
world.EnableFeature<EventsFeature>();
You can schedule an event that will be distributed among all the listener systems during the next frame and will be deleted automatically afterwards. In order to do so, call this.ScheduleEvent
inside IQuerySystem
or World.ScheduleEvent
inside ISystem
:
World.ScheduleEvent(new TestWorldEvent
{
value = 1
});
When scheduling the event this way, you're creating one instance of this event that is not connected to any Entity in your world. Basically, this is considered as a World Event.
You can schedule an event that will connected to specified Entity and be distributed among all the listener systems during the next frame and will be deleted automatically afterwards. In order to do
so, call this.ScheduleEventForEntity
inside IQuerySystem
or World.ScheduleEventForEntity
inside ISystem
:
this.ScheduleEventForEntity(entity, new TestWorldEvent
{
value = 1
});
This way, you're creating an instance of this event that is linked to the entity you've specified.
You can subscribe to world events by using CreateEventListener
:
this.CreateEventListener<TestWorldEvent>()
If you want to receive list of events that were distributed this frame:
this.CreateEventListener<TestWorldEvent>()
.ForAll(events =>
{
foreach (var eventData in events)
{
...
}
});
If you want to receive a world event one by one:
this.CreateEventListener<TestWorldEvent>()
.ForEach(eventData => { summarizedValue += eventData.value; });
There are also many overrides to this function that allows you to receive the Entity and it's components at the same time:
this.CreateEntityEventListener<TestWorldEvent>()
.ForEach((Entity entity, TestWorldEvent testWorldEvent, ref TestComponent testComponent) =>
{
testComponent.value += 1;
});
this.CreateEntityEventListener<TestWorldEvent>()
.ForEach((Entity entity, ref TestComponent testComponent) =>
{
testComponent.value += 1;
});
this.CreateEntityEventListener<TestWorldEvent>()
.ForEach(entity =>
{
testComponent.value += 1;
});
If you're expecting a component that the Entity that received the event doesn't have -> ForEach won't be triggered for this entity!
Be default, the query engine applies checks when you create a query: all the components that you're using in ForEach
should also be defined in a query using With
or WithAll
to guarantee that the
components exist on the entities that the resulting Filter
returns.
This validation only happens once when creating a query so it doesn't affect the performance of your ForEach
method!
However, if you're willing to disable the validation for some reason, you can use .SkipValidation(true)
method:
CreateQuery()
.WithAll<TestComponent, DamageComponent>()
.SkipValidation(true)
.ForEach(...)
If you want to specify that ALL of your queries should only process entities that have component X
or don't process entities that have component Y
, you can use globals feature:
QueryBuilderGlobals.With<X>();
QueryBuilderGlobals.Without<Y>();
Be careful with using globals though - you might have difficult time debugging your systems :)
Make sure you set this before any systems get initialized (once CreateQuery()
is converted to lambda or job, the filter is not mutable anymore!).
You can also disable globals for specific queries by using .IgnoreGlobals(true)
:
CreateQuery()
.With<TestComponent>()
.IgnoreGlobals(true)
.ForEach((Entity entity, ref TestComponent testQueryComponent) =>
{
testQueryComponent.value++;
});
You can override OnAwake
& OnUpdate
methods of QuerySystem
if you want to:
public override void OnAwake()
{
base.OnAwake();
}
public override void OnUpdate(float newDeltaTime)
{
base.OnUpdate(newDeltaTime);
}
Don't forget to call the base method, otherwise Configure
and/or queries execution won't happen!
If you have your own systems that extend ISystem
and you don't want to inherit QuerySystem
class, you can just implement interface IQuerySystem
and implement the logic of executing the lambdas
yourself.
- Thanks to codewriter-packages for Morpeh.Events implementation that was taken as a source for implementing events!
Morpeh.Queries is MIT licensed.