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)
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!
To optimize the performance of your application, consider utilizing Unity's Jobs system and Burst technology to execute calculations in the background while running a query instead of executing them on the main thread. You can find examples of using Jobs in this chapter.
If you want to schedule a job which will run once on every update, you can use this:
public class WaitJobSystem : QuerySystem
{
protected override void Configure()
{
this.ScheduleJob<WaitJob>();
}
}
If you need to initialize your job somehow on every update, use preparation delegate:
public class WaitJobSystem : QuerySystem
{
protected override void Configure()
{
this.ScheduleJob((ref WaitJob job) =>
{
job.millis = 10;
});
}
}
###Query.ScheduleJob (IJobParallelFor)
If you want to schedule a job which will be able to iterate through entities that your query is selecting, use QueryBuilder.ScheduleJob<YourJobType>
to schedule it.
All the fields (NativeFilter
& NativeStash<T>
) will be injected automatically!
Example
[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 CustomJobQueriesTestSystem : QuerySystem
{
protected override void Configure()
{
CreateQuery()
.With<TestComponent>()
.ScheduleJob<CustomTestJobParallel>();
}
}
Results: ~1.6 seconds (1 000 000
entities & 100
iterations)
Supports as many NativeStash's as you want.
You can schedule multiple jobs in one system as well:
public class CustomParallelJobQueriesTestSystem : QuerySystem
{
protected override void Configure()
{
CreateQuery()
.With<TestComponent>()
.ScheduleJob<CustomTestJobParallel>();
CreateQuery()
.With<TestComponent>()
.ScheduleJob<CustomTestJobParallelValue2>();
}
}
They will be executed in parallel.
By default, QuerySystem
won't wait until all the previous and/or inner jobs are completed, but will delegate this logic to the World.JobHandle
instead. In this case, the world will wait for all the jobs in all the systems to be finished.
However, you can change this behaviour by using WaitUntilInnerJobsCompleted
and/or WaitUntilPreviousJobsCompleted
:
public class CustomSequentialJobQueriesTestSystem : QuerySystem
{
protected override void Configure()
{
// let's wait until all the previous jobs (from previous systems) are finished
WaitUntilPreviousJobsCompleted();
// let's schedule first job
CreateQuery()
.With<TestComponent>()
.ScheduleJob<CustomTestJobParallel>();
// scheduling another job that will be run in parallel with the 1st
CreateQuery()
.With<TestComponent>()
.ScheduleJob<AnotherParallelCustomTestJobParallel>();
// let's wait until both jobs that we created to be finished before proceeding to the next system
WaitUntilInnerJobsCompleted();
}
}
You can also force one job to be dependent on another (to only execute when the 1st is finished):
public class CustomSequentialJobQueriesTestSystem : QuerySystem
{
protected override void Configure()
{
var jobHandle = CreateQuery()
.With<TestComponent>()
.ScheduleJob<CustomTestJobParallel>();
CreateQuery()
.With<TestComponent>()
.ScheduleJob<CustomTestJobParallelValue2>(jobHandle);
}
}
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).
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.