Closed Anders429 closed 1 year ago
The additions to the API could be (rough draft):
Modules:
resource
Traits:
resource::Resource
resource::Resources
query::ResourceView
query::ResourceViews
Macros:
Resources!()
query::resource_views!()
query::result
macro)query::ResourceViews!()
query::Views
macro)Structs
resource::Null
Functions
World::get::<R>() -> &R
view_resources()
)World::get_mut::<R>() -> &mut R
view_resources()
)World::view_resources::<V>() -> V
Additionally, the following APIs would change:
World::query()
and World::par_query()
will be changed to allow accessing resources.System
and ParSystem
will both have a new Resources
associated type.System::run()
and ParSystem::run()
will be changed to provide resource views alongside the result iterator.Schedule
claims will need to account for resource access.
Serialize
, Deserialize
, Debug
, PartialEq
, and potentially other traits will need to account for resources.There is also still the question of whether a resource should require a default value (Default
implementation), if resources will need to be provided at World
creation, or if resources should just be allowed to exist in an uninitialized state, returning Option::None
if attempted to be accessed. My instinct is to require them to be initialized at all times, since there is no reason to ever have a resource be uninitialized. If a user wants uninitialized resource states, they can just use Option<Resource>
. But I'm not sure if requiring Default
is cleaner, or if requiring a value on calls to World::new()
, or something else is the way to go. I suppose if the resources all implement Default
, then World
can still implement default, so World::default()
would work just fine, and World::new(resources!())
could also exist.
Might be worth it to consider having two separate constructors for users both with and without resources, following a pattern like this: https://play.rust-lang.org/?version=stable&mode=debug&edition=2021&gist=3482f7bcb34acc8eccfbc382f142853c
In this case, providing a new()
function for no resources, and a with_resources()
function for having resources, seems to be ideal.
This has come up as I've been experimenting with implementing this feature, and discovered that all of the existing tests that called World:::<Registry!(...)>:new()
are now having to be written as World::<Registry!(...), _>::new(resources!())
, which is a pretty scary evolution of the API that is hard to parse for users who aren't even touching resources. World::<Registry!(...), _>::with_resources(resources!(...))
makes sense in the context of using resources, because they have to be provided somewhere, but I'd prefer not to unnecessarily overcomplicate the API.
Querying resources will only be able to be done immutably within iterations of parallel queries and systems. Otherwise, each iteration could attempt to modify a resource at the same time, resulting in race conditions.
Edit: After thinking about this point a bit further, it won't be as big of a deal as I initially thought. The resources will be returned separately from the iterator, so it will be obvious to the user that they are separate. The reason for not being able to use mutable resources within parallel iterations should be obvious to the user.
When I began working on
brood
, resources were deliberately not included in the design. I initially thought that such a pattern was unnecessary, because I figured you could just use resources stored outside of the ECSWorld
object within your systems. My thinking on this has changed now, and I think there may be a good case for resources.Minimal Example
Consider the following example:
Suppose there is some shared counter that we want to update within multiple systems. We also want to run these systems within a
Schedule
, to parallelize them if possible. Currently, we can try to have our counter exist outside of theWorld
, and provide mutable references to the systems. Under the current pattern, a user would probably attempt to write the systems like this:Now, to use these in a schedule, one might try the following:
That looks great, except it doesn't work. Even defining
CountB
whileCountA
exists is not allowed, and for good reason. These two systems would be scheduled together, due to having no overlapping mutable access to any components. However, they both access the counter at the same time, and the result would be a data race.So the next thing to try is moving the two systems into separate schedules, since they both have the same shared mutable reference. This ensures they are not run in the same stage, so accessing each counter should technically be fine.
This is also no good. The same problem occurs: Rust can't tell that you're not accessing the shared resource in a safe manner. Now you might think, but why not just define
schedule_a
, run it, then defineschedule_b
and run it? This does work, but it isn't ideal. In regular usage, you'll be running these schedules repeatedly, so the recommended way is to not instantiate your schedules over and over again, every single frame. While that wouldn't be too bad here, some systems might require more expensive creation. It's better to keep them alive for the program, not just for a frame.The better solution is to treat resources the same way we treat component columns. They should be borrowable just like components and entity identifiers. If the resources were borrowable,
CountA
andCountB
could both declare that they need mutable access to the resource with the typeu32
(or to someCounter
newtype). The scheduler would see that these accesses are incompatible and consequentially schedule them in different stages. Now using singleton resources works the same as using entity components, and you can therefore use them in the systems like you would expect.Detailed Design
Storing resources should be fairly straightforward. The only thing to consider is that users will need to register resources like they do components. A type should be able to exist as both a component and a resource, and resources can be stored in a heterogeneous list to prevent the need for dynamic dispatch.
Accessing a resource will be as simple as providing the resource type, along with an index type inferred at compile time, and getting a reference to the type. It is still undecided whether the resource should be allowed to be uninstantiated or not. It may be best to require resources to implement
Default
to ensure they can be instantiated.Querying should allow access to resources alongside entities. Systems should also be able to access resources alongside entities. In both cases, the resource should be provided alongside the results iterator.
No additional tracking should need to be done for resources. They are not entities, and therefore no integration with the entity allocator will need to be done.
Serialization should be fairly easy, since the resources will be registered at compile-time.
I'm not sure if resources will be required to implement
Any
. It probably will makeDebug
implementations more useful, as well as human-readable serialization. However, some resources may be references to system resources, so there may be problems in that regard.