Skip to content

Commit 7cf9cd9

Browse files
committed
NonUniqueResourceId
1 parent 658472f commit 7cf9cd9

File tree

1 file changed

+281
-0
lines changed

1 file changed

+281
-0
lines changed

rfcs/non-unique-resource.md

Lines changed: 281 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,281 @@
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

Comments
 (0)