Anders429 / brood

A fast and flexible entity component system library.
Apache License 2.0
39 stars 1 forks source link

Resources #168

Closed Anders429 closed 1 year ago

Anders429 commented 1 year ago

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 ECS World 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 the World, and provide mutable references to the systems. Under the current pattern, a user would probably attempt to write the systems like this:

struct CountA<'a> {
    counter: &'a mut usize,
}

impl System for CountA<'_> {
    type Views<'a> = Views!();
    type Filter = filter::None;

    fn run<'a, R, FI, VI, P, I, Q>(
        &mut self,
        query_results: result::Iter<'a, R, Self::Filter, FI, Self::Views<'a>, VI, P, I, Q>,
    ) where
        R: ContainsQuery<'a, Self::Filter, FI, Self::Views<'a>, VI, P, I, Q>,
    {
        for result!() in query_results {
            *self.counter += 1;
        }
    }
}

struct CountB<'a> {
    counter: &'a mut usize,
}

impl System for CountB<'_> {
    type Views<'a> = Views!();
    type Filter = filter::None;

    fn run<'a, R, FI, VI, P, I, Q>(
        &mut self,
        query_results: result::Iter<'a, R, Self::Filter, FI, Self::Views<'a>, VI, P, I, Q>,
    ) where
        R: ContainsQuery<'a, Self::Filter, FI, Self::Views<'a>, VI, P, I, Q>,
    {
        for result!() in query_results {
            *self.counter += 1;
        }
    }
}

Now, to use these in a schedule, one might try the following:

let mut world = World::<Registry!()>::new();
world.extend(entities![(); 1000]);

let mut counter = 0;

let mut schedule = schedule!(
    task::System(CountA {
        counter: &mut counter
    }),
    task::System(CountB {
        counter: &mut counter
    })
);

world.run_schedule(&mut schedule);

That looks great, except it doesn't work. Even defining CountB while CountA 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.

let mut world = World::<Registry!()>::new();

world.extend(entities![(); 1000]);

let mut counter = 0;

let mut schedule_a = schedule!(
    task::System(CountA {
        counter: &mut counter
    })
);
let mut schedule_b = schedule!(
    task::System(CountB {
        counter: &mut counter
    })
);

world.run_schedule(&mut schedule_a);
world.run_schedule(&mut schedule_b);

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 define schedule_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 and CountB could both declare that they need mutable access to the resource with the type u32 (or to some Counter 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 make Debug 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.

Anders429 commented 1 year ago

The additions to the API could be (rough draft):

Modules:

Traits:

Macros:

Structs

Functions

Additionally, the following APIs would change:

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.

Anders429 commented 1 year ago

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.

Anders429 commented 1 year ago

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.