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
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 affect 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
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;
});
}
}
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 systems as well:
public class CustomParallelJobQueriesTestSystem : QuerySystem
{
protected override void Configure()
{
CreateQuery()
.With<TestComponent>()
.ScheduleJob<CustomTestJobParallel>();
CreateQuery()
.With<TestComponent>()
.ScheduleJob<CustomTestJobParallelValue2>();
}
}
This way they will be executed in parallel and the system will wait for both jobs to finish.
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!
Morpeh.Queries is MIT licensed.