Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Write only world query #71

Draft
wants to merge 2 commits into
base: main
Choose a base branch
from
Draft
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
385 changes: 385 additions & 0 deletions rfcs/71-write-only-world-query.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,385 @@
# `WriteOnly` world query

## Summary

Add a `WriteOnly<T>` `WorldQuery`. `WriteOnly` acts like `&mut`, but tells bevy
that the value of component `T` is overwritten unconditionally by the system.
Add the `B0006` error. This tells users that they are modifying a component
that is later overwritten unconditionally.

## Motivation

When I write to the `Transform` of a UI node, it doesn't do anything
and I see no error messages.

Why this happens is fairly trivial: `Transform` is updated by the UI system
in `PostUpdate`, and overwrites the user-set value unconditionally.

This is also true of `AnimationClip`s. A system overwrites bone transforms
targeted by an animations in `PostUpdate`. Even if the `AnimationClip` is
paused!

## User-facing explanation

### A new `WorldQuery`

We introduce a new `WorldQuery`: `WriteOnly<T>`.

```rust
struct WriteOnly<'a, T>(Mut<'a, T>);

impl<'a, T> WriteOnly<'a, T> {
pub fn set(&mut self, value: T) {
*self.0 = value
}
/// Only **write** to the returned reference.
/// If you have a `Vec`, for example, you should not leave any of the
/// previously existing value.
pub fn ref_mut(&mut self) -> Mut<T> {
self.0.reborrow()
}
pub fn into_inner(self) -> Mut<'a, T> {
self.0
}
}

```

`WriteOnly<T>` is very much nothing more than a thin wrapper around `Mut`.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WriteOnly seems like the wrong name for this:

  • We already have the concept of "read only queries". Which means "you statically do not have permission to do anything but read". This is not the "write" equivalent of that.
  • The goal of this feature is not to assert that we only write to a thing (and not read from it). It it is to assert that all values will be written to, with none skipped. Reading before or after the write is completely ok, provided you do the write. WriteOnly not only isn't accurate ... it encourages the user to think about it in the wrong way. If I saw WriteOnly in a query, I wouldn't think "i need to write every item in the query". I would think "i cannot read items in this query but I can still skip items I don't want to write".

Copy link
Contributor Author

@nicopap nicopap Jun 27, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The no-read semantic is important. What makes ui_layout_system handling of Transform different from other systems is indeed that it overwrites the Transform without leaving a trace of the existing value. Whether it does this to all the entities it access or only a subset is only partially relevant.

Reading the previous value disqualifies for WriteOnly

fn sys(mut q: Query<&mut Transform>) {
 for t in &mut q {
    t.translation.x += 1.0;
  }
}

Here, a previous system that changes Transform still has a visible effect after sys runs. So we don't want to say that translation was overwritten.

While for the following:

fn sys(mut q: Query<&mut Transform>) {
 for t in &mut q {
    if t.translation.x > 10.0 {
      continue;
    }
    *t = Transform::IDENTITY;
  }
}

The previous system's value will be overwritten (for a subset of Transform) If the user wrote a system that sets a Transform that has a translation.x smaller than 10.0, that value will never be read afterward. So it has no visible effects. A system that updates a value with no visible effect is most definitively a user error.

Our goal here is an error message similar to rust's unused-assignments lint.


Now, take our 2nd code sample. It still reads from the Transform. It reads the t.translation.x in the conditional. Therefore it also disqualifies for WriteOnly. Since it depends on the previous value. The only situation where WriteOnly applies is:

fn sys(mut q: Query<WriteOnly<Transform>>) {
 for t in &mut q {
    t.set(Transform::IDENTITY);
  }
}

This includes arbitrary query filters. But beyond that, if any trace of the existing value is read by sys, or remains in the Transform, it is a mistake to mark it as WriteOnly

On exhaustiveness

Now I said "if any trace of the existing value remains in the Transform".

Well, precisely. It's still possible to leave as-is (without reading them) pre-existing values. However, we run the risk of false positives.

You could read a value on a 2nd component and use it to decide to write or not on the write-only component.

fn sys(mut q: Query<(&ShouldOverwrite, WriteOnly<Transform>)>) {
 for (overwrite, t) in &mut q {
    if !overwrite.should {
      continue;
    }
    t.set(Transform::IDENTITY);
  }
}

It's up to the user to decide if the risk is likely or worthwhile. I don't see any possibility of using WriteOnly for optimization or anything beyond the error message. So it stays pretty much and mostly aimed at internal engine code and 3rd party plugin code.

With one significant difference: semantically, it cannot be read.

We promise to the scheduler that all values in the `Query<WriteOnly<T>>` will
be overwritten, with no traces of its previous value.

### Write-only access

We add a new method to the `WorldQuery` trait.

```rust
fn update_write_only_component_access(state: &Self::State, access: &mut FilteredAccess<ComponentId>) {}
```

Notice that — unlike `update_component_access` — it has a default implementation.
It is not unsound to erronously populate this `FilteredAccess`. It is just used
as a hint for triggering error messages.

This adds another `FilteredAccess` to the query. This `FilteredAccess`
is only written-to and read-from at app initialization when setting up the systems.

### Triggering the warning

Consider `ui_layout_system`:

https://github.com/bevyengine/bevy/blob/469a19c290861049ad121aa83d72f830d1ec9b40/crates/bevy_ui/src/layout/mod.rs#L215-L230

```rust
pub fn ui_layout_system(
primary_window: Query<(Entity, &Window), With<PrimaryWindow>>,
windows: Query<(Entity, &Window)>,
ui_scale: Res<UiScale>,
mut scale_factor_events: EventReader<WindowScaleFactorChanged>,
mut resize_events: EventReader<bevy_window::WindowResized>,
mut ui_surface: ResMut<UiSurface>,
root_node_query: Query<Entity, (With<Node>, Without<Parent>)>,
style_query: Query<(Entity, Ref<Style>), With<Node>>,
mut measure_query: Query<(Entity, &mut ContentSize)>,
children_query: Query<(Entity, Ref<Children>), With<Node>>,
mut removed_children: RemovedComponents<Children>,
mut removed_content_sizes: RemovedComponents<ContentSize>,
mut node_transform_query: Query<(Entity, &mut Node, &mut Transform, Option<&Parent>)>,
mut removed_nodes: RemovedComponents<Node>,
) {
```

We would replace

```rust
mut node_transform_query: Query<(Entity, &mut Node, &mut Transform, Option<&Parent>)>,
```

with the following:

```rust
mut node_transform_query: Query<(Entity, &mut Node, WriteOnly<Transform>, Option<&Parent>)>,
```

Now, let's say Daniel — a bevy user — want to add a screenshake effect to their UI
(please never do this):

```rust
fn shake_ui(mut nodes: Query<&mut Transform, With<Node>>) {
for tran in &mut nodes {
tran.translation += random_offset;
}
}
```

Daniel adds `shake_ui` to their game to spice things up:


```rust
app.add_plugins(DefaultPlugins::new())
.add_systems(Update, shake_ui);
```

Daniel looks at their game. And *\*sad trombone\** nothing happens.
The UI stays fixed to the screen as if sticked with ultra glue.

**But lo**! What is this? An error in the bevy log output?

```
2023-04-20T11:11:11.696969Z ERROR bevy_ecs: [B0006] In `Update`, the system `shake_ui` writes the `Transform`, but `ui_layout_system` in `PostUpdate` later overwrites it unconditionally.
```

| 👆 **We will refer to this error as `B0006` for the rest of this RFC** |
|------------------------------------------------------------------------|

Daniel reads the error message, goes to <https://bevyengine.org/learn/errors/#b0006>
and learns that they should — I quote — "schedule `shake_ui` in `PostUpdate`,
and label it as `shake_ui.after(ui_layout_system)` to effectively update `Transform`".

Daniel updates their app schedule code:

```rust
app.add_plugins(DefaultPlugins::new())
.add_systems(PostUpdate, shake_ui.after(ui_layout_system));
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This isn't actually "fully correct" for this scenario. Making changes to the Transform after ui_layout_system does ensure it won't get stomped on by that system. And for the current Bevy systems written I do think it would work. But the computed node Transform is considered an output of the UiSystem::Layout set. Anything trying to consume the "final computed layout " of a node should schedule relative to that. Therefore if you are modifying Transform (and affecting final layout), you should put your system in UiSystem::Layout.

```

Daniel recompiles their game and looks at the screen…

It works now! Hurray! Without even having to ask for help.

## Implementation strategy

Ok, so how would we accomplish this feat of ECS user friendliness?

### Detecting overwrites

Let's consider first the naive approach: If a system writes to `&mut Transform`
and later a system that accesses `WriteOnly<Transform>`, then emit `B0006`.

Let's go back to our user Daniel.

They are making a game, so they also have this system:

```rust
fn move_player(player: Query<&mut Transform, With<Player>>) {
// ...
}
//...
app.add_plugins(DefaultPlugins::new())
.add_systems(Update, (shake_ui, move_player));
```

According to our naive logic `Transform` in `move_player`
is "later overwritten by `ui_layout_system`".

But this doesn't make sense! Entities with the `Player` component
**cannot** be overwritten by `ui_layout_system`, since `Player` is never present
on entities that also have a `Node` component!

We should only emit the error if the system changes components on entities that
will be written to by the `WriteOnly` system.

#### Significant component set

Yet, this is still not good enough! Daniel, having reasoned that they shouldn't
shake text — because otherwise it would be completely unreadable — has created
a component they add to all non-text UI entities:

```rust
#[derive(Component)]
struct ShakeUi;

fn setup(mut cmds: Commands) {
cmds.spawn((ImageBundle { /* … */, ..default() }, ShakeUi));
}

// Previously: `With<Node>`
fn shake_ui(mut nodes: Query<&mut Transform, With<ShakeUi>>) // ...
```

Now, Daniel's `shake_ui` query _doesn't have a `Node`_! We can't use the previous
reasoning to detect that `shake_ui` only writes to a subset of entities that
will later be overwritten by `ui_layout_system`.

Ooof, yickes.

But bevy already exposes the information necessary to know that `ShakeUi` only
exists on entities with a `Node` component:

- We are aware of all currently existing archetypes in our world.
- All archetypes describe the components it has.

Here is the gold insight:

Consider a component _C_ and a set of components _K_.

_K_ is the significant set of _C_ when:
∀ archetypes _Ai_,
if _C_ ∈ _Ai_ ⇒ _K_ ⊂ _Ai_.

In plain English: If an entity has the _C_ component, it necessarily also has
all components in _K_.

#### Definition

Let's narrow down the meaning of the words we use, so that we can do
mathematical reasoning on them:

- _system_: A set of _queries_ and a schedule slot
- _query_: three sets of _components_: read, write, write-only.
- _component_: TODO

Now let's define relations:

- query _Qx_ **writes to** component _Cy_ when: _Cy_ ∈ _Ax.write_
- Ditto for "_Qx_ **reads** component _Cy_"
and "_Qx_ **writes-only to** component _Cy_"
- query _Qx_ **subset of** query _Qy_ when:
- ∀ _Ci_ ∈ _Qx_, _Ci_ ∈ _Qy_. (ie: `Qx & Qy == Qx`)
- system _Sx_ **happens before** _Sy_ when the scheduler runs _Sx_ before _Sy_.
- system _Sx_ **overwrites** _Sy_ on _Cz_ when:
- ∃ _Qx_ ∈ _Sx_, _Qx_ writes-only to _Cz_
- **and** _Sy_ happens before _Sx_
- **and** ∃ _Qy_ ∈ _Sy_ such as _Qy_ subset of _Qx_
- **and** _Qy_ writes to _Cz_.

| ❗ **TODO, this definition is not complete** |
|----------------------------------------------|

- **happens before** should be replaced by "directly written-to afterward"
- add the significant component set to the definition

Now we have the mathematical foundations to detect overwrites.

But how would this look like in bevy? Where to check for the overwrites? When?
With which frequency?

| ❗ **TODO, no concrete implementation plans** |
|-----------------------------------------------|

#### False positives

Suppose Daniel writes the following system (they shouldn't!):

```rust
fn move_all(mut query: Query<&mut Transform>)
```

The `Transform` will later be overwritten by `ui_layout_system` for entities
that also happen to have a `Node` parameter. So this is bad.
Yet, Daniel probably didn't mean to write to `Node` entities.

So I think it is a fair to leave this as is.

Modifications to generic components should **always** be scopped to a category
of entities with an additional filter. If the scopped category shares entities
with a `WriteOnly` query, it's probably that something happens that the user
didn't intend.

Therefore, false positives only exist for bad practices patterns,
and are acceptable in this case.

#### False negatives

False negatives only occur when the `WriteOnly` access is poorly scopped.
See the [`animation_player`](#animation_player) section.

## Future works

An advantage of this approach is that we can confidently extend `WriteOnly`
to any kind of component.

We could extend this to `GlobalTransform`, or `ComputedVisibilty` and enable
writing to those, since now we are capable to emit an error message with
no more overhead if they are misused.

Since `WriteOnly` is publicly accessible and is a `WorldQuery` like any other,
it is also possible for end-users and plugin authors to use it in their own
systems, to extend the write-only error detection to their own components
and systems.

## Alternatives

Let's be honest and fully transparent: This is an extremely niche use-case.

- **What we solve**: Confusion when a system mutates a component that is later
unconditionally overwritten
- Currently only relevant to `Transform` in ui and animation

- **What we do** to solve it:
- Add a new `WorldQuery` with special semantics
- Add a new method to `WorldQuery` to register write-only components
- Add more system buraucracy to manage the write-only components
- Add logic in scheduler to verify that components accessed by a system are
not overwritten by write-only queries.
- Add a component to animated entities (more on this later)

Whether the complexity is worth the gain is a serious consideration.

In short, this is why this RFC exists. It's questionable whether we want to add
it to bevy, and I wanted feedback from the community before implementing it.

## Drawbacks

### Fine-grained write-only

Consider a system that only writes-only to `Transform.rotation`. We can't mark
`Transform` as `WriteOnly`, since writing to the `Transform.translation` won't
be overwritten.

But a system that writes to the `rotation` will have its effect overwritten.

No way to account for that.

### Inter-frame dependency

Now this is another tricky bit. If we had added `shake_ui` after `transform_propagate`,
it would have had the same issue. But in the schedule. It doesn't appear
before `ui_layout_system`.

I'm not proposing to solve this in this RFC.

### `animation_player`

As far as I can tell, current day bevy has two systems that might overwrite
unconditionally existing component values:

- `node_transform_query` in `bevy_ui`
- `animation_player` in `bevy_animation`

using `WriteOnly` in `animation_player` would lead to some issue. Let's look
at its signature:

https://github.com/bevyengine/bevy/blob/469a19c290861049ad121aa83d72f830d1ec9b40/crates/bevy_animation/src/lib.rs#L355-L364

```rust
pub fn animation_player(
time: Res<Time>,
animations: Res<Assets<AnimationClip>>,
children: Query<&Children>,
names: Query<&Name>,
transforms: Query<&mut Transform>,
morphs: Query<&mut MorphWeights>,
parents: Query<(Option<With<AnimationPlayer>>, Option<&Parent>)>,
mut animation_players: Query<(Entity, Option<&Parent>, &mut AnimationPlayer)>,
) {
```

Notice:

```rust
transforms: Query<&mut Transform>,
```

Ouch! Converting this to a `WriteOnly<Transform>` would be catastrophic! It would
match every entity with a `Transform` component, hence it would raise the
error **for all systems accessing `Transform`**. Not acceptable!

I see two solutions:

- Insert a `Animated` component to entities targeted by a `Transform` animation
clip, and limit it to `Query<WriteOnly<Transform>, With<Animated>>`.
- Do not convert the query to a `WriteOnly<Transform>`.