Skip to content

Releases: EcsRx/EcsR3

Streamlined Batched/Multiplexed Systems

Choose a tag to compare

@grofit grofit released this 02 Jun 15:30
b06d2d5

It has been a point of discussion for a while now around how BatchedSystems come in varying flavours, to best support class/structs when batching. This has meant a lot of API fragmentation at higher layers due to the many different types of BatchedSystem and their use cases.

You can read more about it here:
#28

Ultimately we had 2 approaches to trial, ref based component params, or write-back returns (where we return back all components for updating if needed), the ref approach won out for a couple of reasons (mentioned in the PR), for now though we have removed all other permeations such as the BatchedMixedSystem, BatchedRefSystem, MultiplexingBatchedRefSystem and just got a single way to do batching and multiplexing.

This hopefully opens up other pathways to improve performance/features in a consistent way.

Adding basic Utility AI plugin

Choose a tag to compare

@grofit grofit released this 04 Nov 12:18
d4847ee

Not much has changed in the core library, but this adds a new Utility AI Plugin.

EcsR3.Plugins.UtilityAI

While there isn't as much information available as I would like about Utility/Infinite Axis style AI, there is enough if you google it so I wont go over that too much here, but here is a quick crash course:

Quick Utility/Infinite Axis AI Overview

The high level idea is that you distil contextual information into normalized values of 0-1 which are then used together to score how good high level actions are.

For example if you have a player with data like { health: number, maxHealth: number, ammo: number, position: vector3 } you can normalize a lot of these values so if you get the health percentage as a normalized value 0-1 then you can use that as a Consideration when factoring in if you should carry out Actions such as if you should take cover or shoot an enemy (assuming you also have enough ammo).

With this in mind we have 2 high level concepts:

  • Considerations - These are normalized numeric abstractions (0-1) over contextual information, such as the health, ammo levels, distance to threats etc
  • Actions - These are higher level actions your agent may want to do based on the Considerations it is given, it decides how good an action is by scoring all related Considerations to provide a feel as to how useful the action is (aka its utility).

With this being said there is a bit more complexity at play under the hood, as the name Utility comes from how useful something is, and having a Consideration between 0-1 is good, but we need to provide some context as to "how good" that consideration is, which is where Curve Functions come in as they allow us to plot our value to a final consideration.

The OpenRpg lib that this plugin uses for curve functions and variables, has a page that explains curve functions in more depth and has its own AI related examples/demos, but while at the high level that library is doing same thing as this its API is quite different. https://openrpg.github.io/OpenRpg/

To give a more detailed example, if we think about 2 high level actions Attack and TakeCover, the Attack action would likely only be a good idea if we have a good amount of health (ignoring ammo levels for now), and the TakeCover action would likely be important if health was low, so if we have 75% health (expressed as 0.75f) is that good or not, for Attacking yeah 0.75f seems like a high score, but for Taking cover 0.75f also seems like a high score, so should both actions be as likely at 75% health? (no they shouldnt).

So jumping back to curve functions, lets create 2 Considerations based off the same underlying input (Health Percentage):

  • Healthy Consideration
image

If we pass in 0.75f to the linear function we will get out 0.75f which is fine, as it makes sense as we are quite healthy.

  • LowHealth Consideration
image

Notice how we have an inverse linear function, which is the opposite, so passing in 0.75f would output 0.25f, meaning that its not actually that high at the moment.

Taking the 2 considerations together, we can drive the Attack action from Health consideration, and the Take Cover action from the LowHealth consideration.

You could even get fancier and express LowHealth with a more drastic curve where anything above 50% health is seen as fine, its only when you get to less than 50% health it DRASTICALLY ramps up
image

Either way the input value alone needs to be run through a curve function to plot how high the value actually is, so the same inputs can be considered multiple ways.

With this taken care of now if we have 75% health our Attack would score 0.75f and TakeCover would score far lower at 0 (on latter graph).

Just before we finish off there is one more thing to mention, which is how Actions can factor in multiple Considerations so in the above example its fine having a high Health consideration, but we would also need to ensure we had Ammo and were close enough for our weapon to hit an enemy, or if there is even an enemy there.

For example if we had 75% health, and an enemy pretty close, if we have no ammo we cant shoot, so taking imaginary consideration scores like:

  • Health 0.75f
  • EnemyNearby 0.75f
  • Ammo 0f

It doesnt matter that 2 of the Considerations are high, as the ammo one is 0, which acts as a sort of veto and will mean when they are calculated together as when multiplied by 0 its going to be 0, which makes the action a no go. Also as the Considerations are all normalized between 0-1 you can use as many of them as you want, which is where the Infinite Axis name comes from, you can add as many axis/considerations as you want to score your actions, which makes it a very additive system as you come up with newer Considerations you may want to factor them into your Actions.

The Action score is not quite as simple as multiplying all the normalized Considerations together, as the numbers would continue to shrink the more you have. So there is a compensator built into the calculation to stop this, so the numbers will remain accurate, so you cant just Average, or Sum and Divide etc.

Finally worth mentioning we use the term Advice rather than Action as we split the scoring and doing of the Action into different things, so the scoring part of an action is the Advice and we leave you to do the actual Action execution elsewhere.

Ok thats enough, how do we use it?

The plugin contains a simple implementation of the above paradigm, mainly focusing on:

  • AgentComponent - Component containing scores for considerations/actions
  • ConsiderationSystem - Handles recalculating scores for considerations (i.e AmmoRemaining, LowHealth etc)
  • AdviceSystem - Handles taking multiple considerations and calculating a total score for a given action (i.e ShouldHealSelf, ShouldAttack etc)

These 3 things alone will provide you with a beating heart of scores telling you what actions have the most utility at a given time, as its all driven by BatchedSystems under the hood you can schedule them however you want, however these systems only deal with giving you the numeric scoring and the DOING of the action is left for you to do separately, however we do provide some helper systems you can use such as:

  • AdviceActionSystem - This provides a basic system that can execute logic for a given Advice/Action
  • AdviceRankingSystem - This is a simple ranking system that periodically ranks the advice, as by default you just get lots of scored advice and would need to rank them yourself, but this is loaded by default with the plugin.

I heard you all liked breaking changes...

Choose a tag to compare

@grofit grofit released this 24 Oct 20:19
d61a1c6

I JEST!!! kind of...

So this release is quite a big one, and really the only thing I wanted to add was MultiplexingSystem and it ended up highlighting some allocation issues which while a problem in benchmarking scenarios, is not really an issue for most real world situations, but as there was now clear metrics on some slow parts it seemed like a good time to spike some solutions.

Enter ILazyComputed

To quote my good friend Edgar Vautrine

You don't want the very best. You want cheap. And I got cheap

This is something that was toyed with before but due to the old Computed approach being a bit more complex it wasn't really viable, however with the previous few versions re-writing most of the Computed eco system, it now felt like a good time to give it another try, and it works pretty well.

Until now all computed classes would re-evaluate their data on a dependent change, i.e if your data source was a ComputedEntityGroup and it changed, it would automatically refresh its internal computed data, however in reality you don't really need to do this for all computeds, its only when something asks for the Value that you need to refresh the data, as the Lazy prefix suggests.

As part of this the Lazy computeds offer you a new OnHasChange<Unit> which rather than giving you the payload can notify you if the computed data should be refreshed, which internally drives an IsDirty flag.

Historically if you had 100 entities join a group and you requested the Value you would have had 100 refreshes of the data occur internally so the Value was always up to date even if you didn't need it, however now it will only result in a single refresh of data which occurs at point of getting the Value, internally it knows 100 changes have happened but it wont bother refreshing the computed data until you need it.

This also means that the OnChanged will only get triggered when the internal refresh occurs, so using the above example you would have had 100 OnChanged triggers, but now you will only get 1 when the Value is read, if this is a problem for you then you can always use the OnHasChange and subscribe to it, and have that run ForceRefresh() which will forcibly refresh internal state and trigger OnChanged<T>.

This may not be for everyone and thats why we still have the non lazy variants but internally we have moved a lot of innards over to use this new lazy approach which reduces allocations, cpu time and surplus chatter of observables.

You said something about MultiplexingSystem before?

Yeah so this is a new system type, because we don't have enough of them already 😄.

Gist of it is that its a BatchedSystem but rather than it having its own logic, it delegates to IMultipledJob which contains the logic, allowing you to run several jobs with the same components in the same schedule, you can read more in the docs for it.

For example if you had 3 systems which ran every update and took the same components, even though they would all require same data it would be accessed separately meaning it would need to read out components for each system and pass them in to execute. This side steps that by only reading the batches once, and them passing them to each job back to back, so you only pay for the batch lookup once not per system.

Other Minor Changes

  • IPool now has Clear method, most of its implementations had one already anyway so made it more formal at interface level
  • IEntityCollection has renamed RemoveAll to Clear to keep in line with other collection terminology
  • IComputedComponentGroup exposes a ref ReadOnlyMemory<T...> Batches {get;} which allows you to pass the ref to the pool data, sidestepping some byval pass throughs
  • Lots of higher level Computed objects like IComputedComponentGroup, IComputedEntityGroup and others now use the Lazy computed interfaces/inheritance
  • Lots of computeds that exposed their own RefreshData method are streamlined and replaced with ForceRefresh

Also a big thankyou to Jetbrains for renewing our open source license for dotTrace and dotMemory which made investigating and diagnosing all this stuff a lot easier.

Huge Breaking Changes, But Mainly For IEntity

Choose a tag to compare

@grofit grofit released this 04 Jun 10:52

Huge Breaking Changes

For a while we have been discussing changing IEntity to have less responsibility and potentially converting it into a struct to make batch allocations possible and easier, however we needed to do other work such as taking allocations out of the entity and putting them somewhere centralised, as well as moving events from the entity to somewhere else.

As part of previous changes we already moved the events out and now we have moved the allocations out, so it was finally time to retire IEntity and just have Entity with a new IEntityComponentAccessor which allows you to do all the same functionality you used to do on the entity directly but in a more streamlined way, and it also now allows for batch interactions which we couldn't do before due to actions taking place on an entity at a time.

Here are a list of the high level breaking changes:

  • IEntity no longer exists, Entity represents the handle to entity component data IEntityComponentAccessor provides functionality to access entity component state
  • All objects that used to return or store IEntity now store Entity and will often provide IEntityComponentAccessor within scope, if not you can easily inject it in yourself
  • IReactToEventSystem now has an ObserveOn method, you can just return the observable unless you need to do something like observable.ObserveOnMainThread
  • IReactToGroupEx has been replaced with notion of Augments

In reality there are massive changes to the APIs under the hood but as a consumer these are the only things you will likely care about for now.

As briefly mentioned we now have the notion of Augments, where handlers can check to see if more cross cutting interfaces have been added and do logic related to it.

For example we had IReactToGroupEx which was just an IReactToGroup with Before/After Processing methods added, but we also had this notion on BatchedSystems and there was no reason it couldnt be on other systems too, so we have moved this notion out into its own ISystemPreProcessor and ISystemPostProcessor which lets you hook into before/after Process/Execute methods have been run, this should work with most ReactTo* based systems and basic entity systems.

We may add more augments in the future but for now this should allow you to hook into before/after the main running of systems to cache data etc.

This was more of a WIP style idea, so depending on feedback it may be altered more in the future.

Huge Computed and Batching Changes

Choose a tag to compare

@grofit grofit released this 26 May 16:26
a432803

Computed Changes

As part of ongoing work on the internals of the framework we have now altered how components are handled as well as streamlining the notion of the ObservableGroups to fall away to become simple ComputedEntityGroups. The underlying functionality isnt that much different but given that its a computed and as is a lot of other internal things you can build your own computeds off them.

As part of this we now have the following base computed interfaces:

  • IComputed - Base computed that exposes a Value and OnChanged observable
  • IComputedCollection - builds on top of the base computed and adds notion of collections
  • IComputedList - builds on top of both to provide list based data

Computed conventions available now are:

  • ComputedFromData - Take anything and make it computed
  • ComputedFromObservable - Take anything thats observable and compute data from it
  • ComputedCollectionFromData - Take anything and make it a computed collection
  • ComputedCollectionFromObservable - Take any observable and compute a collection from it
  • ComputedListFromData - Take anything and make it a read only list
  • ComputedListFromObservable - Take any observable and computed a list from it

Finally we have higher level computeds that build off entity and component computed groups:

  • ComputedFromEntityGroup - Take a group of entities and compute some data
  • ComputedEntityGroupFromEntityGroup - This one basically takes an existing entity group and constraints if further
  • ComputedFromComponentGroup - Take a component group and compute some data

This hopefully streamlines what was there before and removes some of the more complicated aspects, such as refresh lifecycles etc.

Whats that ComponentGroup all about?

Historically we used to have IObservableGroup which computed which IEntity instances were part of a given Group, this worked well and is one of the back bones of the framework, with most ISystem implementations in EcsR3/Rx making use of these to get the related entities and execute logic against them.

The problem here though is its quite slow, and while we had the IBatchedSystem notion and have done for years, it was always a plugin, meaning you could only make use of that functionality in the provided system conventions from the plugin. However at the heart of it, there was 2 things happening:

  1. It would compute all components for an IObservableGroup making it quick to execute on them
  2. It would expose a system that provided you the components and the entity Id rather than you having the entity and needing to get the components yourself.

This is what most normal ECS frameworks do, and this is why the ECS paradigm is fast, because you have all your data in memory ready to execute very quickly. This was the driving reason behind making the IBatchedSystems because for performance scenarios it was a quick and easy way to execute logic against class or struct based components.

After using them for a bit it became apparent that a lot of use cases surrounded the computed componentsnotion, and so this was brought into the core library as a layer on top of the oldIObservableGroupwhich is now known asIComputedEntityGroup, so we have now got an IComputedComponentGroup` which keeps a maintained list of all components associated with an entity group.

This is mainly used for IBatchSystems which are now part of the core library too, but they also can be useful for making computed data, i.e lets say you wanted to keep track of how far away all enemies are at a given time, you could go through every enemy entity getting their transform component and doing a distance check, but it is VERY slow. Whereas now you can use the ComputedFromComponentGroup which provides you all the components you need to quickly iterate over and track the results.

They also use the .net Memory/Span objects under the hood to keep allocations down and provide a fast way to access and read component data.

Other Changes

  • New CreateMany extension methods for IEntityCollection to allow for var enemyEntities = EntityCollection.CreateMany<EnemyBlueprint>(1000);
  • New CreateComponent method on IEntity which for struct based components provides a way to easily make components without needing to faff with the component types etc
  • EcsR3Application objects now come with the IComputedComponentRegistry and IComputedEntityRegistry which replaces the older IObservableGroupManager interface/notion.
  • The Batching plugin no longer exists and all functionality has been rolled into the core lib

Streamlining of Entity Component event routing

Choose a tag to compare

@grofit grofit released this 10 May 14:34
7bfb9c6

Entity Component Routing Changes

This release is mainly a refactor around how the internal component changes on entities are handled.

Historically there was quite a lot of layering to the process, you had each entity firing events to indicate that it has had components added/removed etc, then that got passed up to the containing IEntityCollection it was in, then depending on the scenario that would then relay the events onto the IEntityDatabase which would then pass to an IObservableGroupTracker which would decide if the entity needed to be put into or leave a group etc.

The main problems here were that the entity was used as a source of event subscriptions and there was a lot of logic done at the upper layers to work out based on the changes and the components on the entity if it needed to change how it sat in Observable Groups etc,

So as part of the last change we refactored how this worked so there is now an EntityChangeRouter which the Entity notifies directly of changes, then the IObservableGroupTracker implementation (only one of them now) will look at the groups changed, find out what IObservableGroups need to be notified and update them.

This means in the long run the Entity has less responsibility so can be refactored easier, which is something that has been thought about for a long while.

IEntityDatabase removed

Finally for this section this also means there is no longer a need to partition the entities as they used to be done, as the IEntityDatabase was mainly created as a central way to be able to handle multiple IEntityCollections at once, with each one being used as a way to partition entities to reduce event chatter between groups, this did work somewhat but ended up making a large amount of the rest of the infrastructure more complex a you couldnt just have a single IObservableGroup for a group, you had to ensure it was for the specific pools too.

With this being removed it means everything is far more streamlined and we instead just pass around the IEntityCollection which would be the same as the World object in a lot of other ECS systems.

Pooling Changes

Also as part of this we have streamlined some of the pooling mechanisms so there is now an IObjectPool which can be used as a base pool to store long running pooled objects, the ViewPool now uses this, and there is a new EntityPool which allows you to pool entities of a given group type so they can be re-used rather than new ones constantly being needed, which can be very useful for situations where there is a lot of churn in the view layer, but you may want to just keep re-using the same underlying Entity objects and View objects.

There is also a new ComponentDatabaseConfig class that lets you configure how component pools should be initialized and expanded, by default you do not need to worry about this, but as your project grows from tens, to hundreds, to thousands of entities you may want to provide more explicit pooling config so you can allocate more up front and do more with your expansions, as each expansion of pools incurs a large allocation cost to do, so making pools need to expand less often and to larger sizes to fit your needs can yield large benefits, here is an example of that.

image

The image shows a simple scenario where the application starts and 100,000 entities are created and have components added, the top image is pre allocating the required component pool sizes up front to minimize expansions. The middle uses the new defaults which are more meant for hundreds to thousands of entities, not hundreds of thousands but it still allocates less and is faster, then finally the last image is the previous version with its default settings for pooling prior to these changes.

Allocation Improvements

There has also been a small amount of work to improve allocations in various parts of the system. This being said given that EcsR3/Rx uses classes for its entities and components to give you more flexibility, that is always going to mean performance and allocations will never be as low as in ECS frameworks where they are all struct based (you can use structs with EcsR3 though, just most people dont :D )

Anyway as always let us know if you have any problems or notice any oddities, the downstream libs like EcsR3.Unity etc will get updated in time with these new changes.

First Release

Choose a tag to compare

@grofit grofit released this 04 Jan 11:52

This is based on the branch from EcsRx with some extra locks added in as we were undergoing some locking work at the time. In terms of Nuget packages there should be R3 equivalents of the previous Rx ones, and you no longer need MicroRx or ReactiveData polyfill libraries.

As always any issues let us know in the issues/discord.