|
| 1 | +# Feature Name: non-unique-res |
| 2 | + |
| 3 | +## Summary |
| 4 | + |
| 5 | +Introduce new builtin to Bevy ECS: non-unique resource. |
| 6 | +Standard `Resource<T>` is unique per `<T>`, this new resource is not. |
| 7 | +`NonUniqueResourceId<T>` is a low-level primitive, not usable as `SystemParam`. |
| 8 | +But higher level tools can be built on top of it, in particular |
| 9 | +by exposing resource as read/write systems which can be piped with other systems. |
| 10 | + |
| 11 | +## Motivation |
| 12 | + |
| 13 | +`Resource<T>` works as equivalent of rwlock in other concurrent systems, |
| 14 | +where `Res<T>` is a read lock and `ResMut<T>` is a write lock. |
| 15 | + |
| 16 | +Bevy limits one instance of such rwlock per `<T>` which makes impossible |
| 17 | +to use it as generic rwlock to build complex systems on top of it. |
| 18 | + |
| 19 | +So, why multiple instance of `Resource<T>` would be useful? |
| 20 | +There are at least several reasons: |
| 21 | + |
| 22 | +### Piping with dependencies |
| 23 | + |
| 24 | +The issue is described in [Bevy issue #8857](https://github.com/bevyengine/bevy/issues/8857). |
| 25 | + |
| 26 | +Current `.pipe()` implementation combines two systems into one, reducing concurrency. |
| 27 | + |
| 28 | +We want to be able to do something like `system1.new_pipe(system2)` which can implemented as: |
| 29 | +* allocate new `NonUniqueResourceId<T>` where `<T>` is the type of the result of `system1` |
| 30 | +* `system1` is modified to write the result to the resource |
| 31 | +* `system2` is modified to read the result from the resource |
| 32 | +* `system2` is scheduled to run after `system1` |
| 33 | + |
| 34 | +To make it work, we need to be able to allocate multiple instances of `Resource<T>` |
| 35 | +because pairs of systems may have the same intermediate type `<T>`. |
| 36 | + |
| 37 | +More complex piping scenarios should be possible, for example: |
| 38 | + |
| 39 | +```rust |
| 40 | +fn system_with_two_outputs() -> (u32, String) { /* ... */ } |
| 41 | + |
| 42 | +let (pipe_32, pipe_str) = system_with_two_outputs.split_tuple2(); |
| 43 | + |
| 44 | +pipe_32.new_pipe(system1); // pipe to a system which takes u32 as input |
| 45 | +pipe_str.new_pipe(system2); // pipe to a system which takes String as input |
| 46 | +``` |
| 47 | + |
| 48 | +Possibilities of building such systems are endless. |
| 49 | + |
| 50 | +### System autolinking |
| 51 | + |
| 52 | +There's an idea of API like this: |
| 53 | + |
| 54 | +```rust |
| 55 | +fn system1() -> String { /* ... */ } |
| 56 | +fn system2(input: In<String>) { /* ... */ } |
| 57 | + |
| 58 | +// This should schedule `system2` after `system1` and pipe the result, |
| 59 | +// like `system1.new_pipe(system2)` above does, except automatically. |
| 60 | +app.add_system_auto_link(Update, system1); |
| 61 | +app.add_system_auto_link(Update, system2); |
| 62 | +``` |
| 63 | + |
| 64 | +We can _mostly_ implement it with resources, however, we should use different |
| 65 | +resources for different schedules, so `NonUniqueResource<T>` would be useful here. |
| 66 | + |
| 67 | +### New conditions/dynamic conditions |
| 68 | + |
| 69 | +#### Possible to reimplement/simplify current conditions |
| 70 | + |
| 71 | +Conditions can be rewritten as regular systems. Consider this code `system1.run_if(cond1)`. |
| 72 | + |
| 73 | +We can reimplement it as: |
| 74 | +* allocate new `NonUniqueResourceId<bool>` |
| 75 | +* `cond1` writes the result to the resource |
| 76 | +* `system1` reads the result from the resource, and if true, runs, otherwise skips |
| 77 | +* `system1` is scheduled to run after `cond1` |
| 78 | + |
| 79 | +This way we can remove support for conditions from scheduler greatly simplifying it. |
| 80 | + |
| 81 | +#### Conditional conditions |
| 82 | + |
| 83 | +But in addition to that, there are scenarios which are not possible to implement currently. |
| 84 | +Consider this pseudocode: |
| 85 | + |
| 86 | +```rust |
| 87 | +fn run_if_opt(system: impl System, cond1: Option<Condition>) -> impl System { |
| 88 | + if let Some(cond1) = cond1 { |
| 89 | + system.run_if(cond1) |
| 90 | + } else { |
| 91 | + system |
| 92 | + } |
| 93 | +} |
| 94 | +``` |
| 95 | + |
| 96 | +This cannot not work, because `run_if` returns `SystemConfigs`, not `System` |
| 97 | +(this is a strict requirement given how scheduler is implemented). |
| 98 | +And user might want to get `run_if` because a user may want to continue working with system, |
| 99 | +for example, pass the resulting system to this function again. |
| 100 | + |
| 101 | +### Dynamically typed systems |
| 102 | + |
| 103 | +Systems can built dynamically, or even bindings to dynamically typing languages can be used. |
| 104 | +For example, for Python custom systems can be implemented like this: |
| 105 | + |
| 106 | +```python |
| 107 | +res = Resource() |
| 108 | + |
| 109 | +@system(ResMut(res)) |
| 110 | +def system1(res): |
| 111 | + res.insert(10) |
| 112 | + |
| 113 | +@system(Res(res)) |
| 114 | +def system2(res): |
| 115 | + print(res.get()) |
| 116 | +``` |
| 117 | + |
| 118 | +If it is dynamically typed, so all resources need to have the same type like `PyObject`. |
| 119 | + |
| 120 | +### Reimplement resources |
| 121 | + |
| 122 | +FWIW, regular resources can be reimplemented on top of non-unique resources. |
| 123 | + |
| 124 | +## User-facing explanation |
| 125 | + |
| 126 | +This section describes user-facing API. |
| 127 | +Again, this is probably won't be used directly by most users. |
| 128 | + |
| 129 | +### Model |
| 130 | + |
| 131 | +Perhaps the best way to think about `NonUniqueResourceId<T>` as `Arc<RwLock<T>>`. |
| 132 | + |
| 133 | +Note `RwLock` does not necessarily mean that the thread should be paused |
| 134 | +if the resource is locked. For example, `tokio` version of `RwLock` |
| 135 | +does not block the thread, but instead put away the task until the lock is released. |
| 136 | +This is what Bevy schedule would do, except it would block before the system start |
| 137 | +rather than during access to the resource (same way as it does for `Resource<T>`). |
| 138 | + |
| 139 | +### API |
| 140 | + |
| 141 | +Let's start defining the reference to the resource. |
| 142 | +This is the reference (the identifier), the resource itself is stored in the world. |
| 143 | +There's no lifetime parameter. |
| 144 | + |
| 145 | +```rust |
| 146 | +/// Reference to the resources. |
| 147 | +/// As mentioned above, it cannot be used as `SystemParam` directly, |
| 148 | +/// but can be used to build higher level tools. |
| 149 | +struct NonUniqueResourceId<T> { /* ... */ } |
| 150 | +``` |
| 151 | + |
| 152 | +How to create the resource: |
| 153 | + |
| 154 | +* `NonUniqueResourceId<T>` can be either requested from `World`, like `world.new_non_unique_resource::<T>()` |
| 155 | +* or maybe just lazily allocated on first access to the world. Like this: |
| 156 | + |
| 157 | +```rust |
| 158 | +impl<T> NonUniqueResourceId<T> { |
| 159 | + /// Generate unique resource id. |
| 160 | + /// The world will allocate the storage for the resource on first modification. |
| 161 | + fn new() -> Self { /* ... */ } |
| 162 | +} |
| 163 | +``` |
| 164 | + |
| 165 | +Raw API to read and write resource. |
| 166 | + |
| 167 | +```rust |
| 168 | +impl World { |
| 169 | + fn get_non_unique_resource<T>(&self) -> Option<&T> { /* ... */ } |
| 170 | + |
| 171 | + fn get_non_unique_resource_mut<T>(&mut self) -> Option<&mut T> { /* ... */ } |
| 172 | +} |
| 173 | +``` |
| 174 | + |
| 175 | +(Similarly, there might be more accessors here or in `UnsafeWorldCell` or in `App` |
| 176 | +which are omitted here for brevity.) |
| 177 | + |
| 178 | +For most advanced users, however, API provides "systems" which read or write resources: |
| 179 | + |
| 180 | +```rust |
| 181 | +impl NonUniqueResourceId<T> { |
| 182 | + /// Return a system which takes `T` as input and writes it to the resource. |
| 183 | + /// This system requests exclusive access to the resource. |
| 184 | + fn write_system(&self) -> impl System<In=T, Out=()> { /* ... */ } |
| 185 | + |
| 186 | + /// Return a system which takes `T` from the resource, |
| 187 | + /// panicking if the resource is not present. |
| 188 | + /// This system also requests exclusive access to the resource. |
| 189 | + fn read_system(&self) -> impl System<(), Out=()> { /* ... */ } |
| 190 | + |
| 191 | + /// Return a system which copied `T` from the resource, |
| 192 | + /// panicking if the resource is not present. |
| 193 | + /// This system only requests shared access to the resource. |
| 194 | + fn read_system_clone(&self) -> impl System<(), Out=T> |
| 195 | + where |
| 196 | + T: Clone, |
| 197 | + { /* ... */ } |
| 198 | + |
| 199 | + /// Like `read_system`, but returns `None` instead of panicking |
| 200 | + /// if the resource is not present. |
| 201 | + fn read_system_opt(&self) -> impl System<(), Out=Option<T>> { /* ... */ } |
| 202 | + |
| 203 | + // ... and several more similar operations. |
| 204 | +} |
| 205 | +``` |
| 206 | + |
| 207 | +System implementations provided by `NonUniqueResourceId<T>` do the heavy lifting |
| 208 | +of configuring the concurrency of the resources. |
| 209 | + |
| 210 | +### Example |
| 211 | + |
| 212 | +Complete very simple system piping example: |
| 213 | + |
| 214 | +```rust |
| 215 | +fn new_pipe<T>(system1: impl System<In=(), Out=T>, system2: impl System<In=T, out=T>) -> SystemConfigs { |
| 216 | + let res = NonUniqueResourceId::new(); |
| 217 | + let barrier = AnonymousSet::new(); |
| 218 | + |
| 219 | + let system1 = system1.pipe(res.write_system()); |
| 220 | + let system2 = res.read_system().pipe(system2); |
| 221 | + IntoSystemConfigs::into_configs(( |
| 222 | + system1.before(barrier), |
| 223 | + system2.after(barrier), |
| 224 | + )) |
| 225 | +} |
| 226 | +``` |
| 227 | + |
| 228 | +## Implementation strategy |
| 229 | + |
| 230 | +Simplified, the `World` gets a new field: |
| 231 | + |
| 232 | +```rust |
| 233 | +struct World { |
| 234 | + non_unique_resources: HashMap<NonUniqueResourceId<ErasedT>, ErasedT>, |
| 235 | + // ... plus some data to track changes. |
| 236 | +} |
| 237 | +``` |
| 238 | + |
| 239 | +PR prototyping this feature: [#10793](https://github.com/bevyengine/bevy/pull/10793). |
| 240 | +The prototype implementation is incorrect, but it shows the general idea. |
| 241 | + |
| 242 | +## Drawbacks |
| 243 | + |
| 244 | +The is added feature, not modification of existing features. |
| 245 | + |
| 246 | +So the main drawbacks come from extra complexity: |
| 247 | +* more bugs |
| 248 | +* maintenance burden |
| 249 | +* harder to learn API |
| 250 | + |
| 251 | +Another potential drawback is giving users too much power and flexibility |
| 252 | +to design their systems. This can be viewed as a benefit, but also can be viewed |
| 253 | +as a drawback, because for example, API of Bevy mods might be harder to understand. |
| 254 | + |
| 255 | +## Rationale and alternatives |
| 256 | + |
| 257 | +The reasons to have this feature are described above in the motivation section. |
| 258 | + |
| 259 | +Alternative, considering we need more than one instance of given `Resource<T>` |
| 260 | +there are strategies to achieve that with added complexity in given code. |
| 261 | +For example, to `new_pipe` function described above, |
| 262 | +we can pass extra `Id` type parameter to generate unique resource type |
| 263 | +like `(T, [Id; 0])`. This may be not very ergonomic, |
| 264 | +it increased code size and reduces compilation speed, but it is possible. |
| 265 | + |
| 266 | +## Prior art |
| 267 | + |
| 268 | +I'm not aware of these. |
| 269 | + |
| 270 | +## Unresolved questions |
| 271 | + |
| 272 | +- Naming. `NonUniqueResourceId<T>` is mouthful. `RwLock<T>`? |
| 273 | +- `NonUniqueResourceId::new` or `World::new_non_unique_resource`? |
| 274 | + |
| 275 | +## Future possibilities |
| 276 | + |
| 277 | +Rewrite parts of Bevy on top of this new feature: |
| 278 | +- Rewrite conditions as regular systems using `NonUniqueResourceId<bool>` |
| 279 | +- Rewrite `Res<T>` and `ResMut<T>` systems using `NonUniqueResourceId<T>` |
| 280 | +- Provide better piping API ([#8857](https://github.com/bevyengine/bevy/issues/8857)) |
| 281 | +- Implement system "autolinking" (automatically connect systems which have matching input/output types) |
0 commit comments