Introduction
Usability in Amethyst needs improvement. We ask a lot of our users in the first few minutes of using the engine, and it’s been repeatedly cited both internally and externally as one of the biggest reasons to not use the engine right now.
Knowledge of all of the following things are required to get started with Amethyst
- Lifetimes
- Associated types
- Traits with lifetimes
- The fact that we implement traits for generic tuples
- Read/Write requires Default, this is usually fine but can be a sneaky requirement
- Having a system subscribe to events is mildly awkward and disjointed.
- Defining System dependencies sometimes requires crawling the source code to find the specific magic string.
These are our biggest barriers to cleaner more usable code.
So what can we do about them?
Well a lot of this comes from our reliance on specs
. It’s a fantastic piece of software, but the inability of it to cater to our needs specifically is starting to hurt us. Which is why I propose a fork from specs, to create an ECS tailor made for our purposes. The tentative name I’ve given this project is igneous
, as igneous rock is the foundation upon which Amethyst crystals grow.
So what changes would we make to specs
if it existed entirely to serve Amethyst?
Lifetimes and Traits with lifetimes
It has been demonstrated that there is no known use case for an API that supports non-static lifetimes in the dispatcher. If we change all of the references in specs with these lifetimes to lifetime 'static
then we can completely eliminate this learning barrier.
Associated Types
It’s important for the ECS to know in advance what kind of resource access a system is going to need, otherwise it can’t schedule the system properly. Thus far, we’ve used tuples and associated types to accomplish this. Our current approach has several drawbacks.
- Uses
Read
andWrite
instead of&
and&mut
, introducing new types needlessly - Implementing the
SystemData
trait on tuples places an arbitrary limit on how many parameters a system can take. Right now that limit is 32. This can be expanded by nesting your tuples, but that’s kind of a silly workaround. - Confusing when
Read
andWrite
need to be swapped out forReadExpect
andWriteExpect
- Resource initialization is disjointed and resources cannot have dependencies on other resources. Additionally
System::setup
methods can’t use a resource if it’s initialized by another system later in the chain.
So what can we do about this?
It’s important to remember associated types exist for a reason. Otherwise we’d have to hurt performance, and make some assumptions about dynamically returned types. But they also exist primarily as an implementation detail, which means they don’t have to be a cognitive load on the user.
We’ve created a proof of concept in the past demonstrating a system can be automatically generated from a function with procedural macros. We decided not to go with this approach because it meant that if your system needed to store caching information, or an event subscription ID you’d have to use a different approach and syntax. However, if we alter that approach we can support a system storing its own data. We keep most of our existing structure, but instead we add the procedural macro attribute to a member function, taking &mut self
or &self
.
In one fell swoop we’ve removed the need for associated types in code, implementing SystemData
on tuples, Read
, Write
, ReadExpect
, and WriteExpect
.
Resource/System Initialization
We’ve demonstrated that data can have dependencies, much like a system can. So, I believe it’s time to make that an explicit part of our API. Using a similar procedural macro approach to what was described above, we can mark a function returning Self
as an initialization function, automatically turning the parameters to the function into data dependencies*. In this new model you can even think of System
s as a resource, they just also have an associated function that gets executed every dispatch.
* If a set of resources creates a circular dependency, or any other kind of dependency chain that can’t be resolved, the ECS will panic.
Misc. .join()
improvement
This syntax doesn’t always make sense to the uninitiated and places an arbitrary limit on how many things you can join across, so I propose altering this to be a macro join!(&foo, &mut bar)
which would automatically generate the join iterator in-line. This removes the limit on how many things can be joined and is a bit more consistent with general Rust syntax. We use something similar in the Hello World of Rust.
fn main() {
println!("Hello world!");
}
Review
So let’s review, of the things discussed above what’s been handled?
LifetimesAssociated typesTraits with lifetimesThe fact that we implement traits for generic tuplesRead/Write requires Default, this is usually fine but can be a sneaky requirement-
Having a system subscribe to events is mildly awkward and disjointed.(Handled in Resource/System initialization) - Defining System dependencies sometimes requires crawling the source code to find the specific magic string.
Just one thing left. System
dependencies. I think we can resolve this pretty simply. Rather than relying on magic strings, we just use the TypeId
internally for the same purpose, and require System
to implement Any
. Since we’re already removing lifetimes above, this doesn’t introduce any unnecessary requirements as anything 'static
is also Any
.
Hey what about scripting? We can’t use macros outside of Rust.
Implementing approaches with similar benefits for scripting will likely require us to make tie-ins specific to the scripting language being used. They may not have the same performance advantages that native Rust code would, but then again we’re not expecting scripting to be incredibly fast. We’d need to implement the ECS macros in the scripting language (maybe using functions or whatever other language specific tools are available), and have those implementations tie in to the Rust implementation of the concepts. Likely at a performance disadvantage.
Closing
I’d like feedback on these ideas. I expect it to be controversial because it leans on macros, and introduces huge changes to our workflow however I am requesting that everyone please give it a chance. Usability is something we’ve been hurting for a lot, and we’ve done it all in the name of performance. I believe we can achieve similar performance, greater usability, and a more robust API with these approaches.