Skip to content

Allow plugins to define component requirements #15367

@Jondolf

Description

@Jondolf

This is similar to #14927, but more scoped, and with a slightly more flexible API.

What problem does this solve or what need does it fill?

Currently, required components work only through the require attribute on component types. For example:

#[derive(Component)]
#[require(Mass, SleepTimer, ...)]
pub struct RigidBody;

#[derive(Component, Default)]
pub struct Mass(pub f32);

#[derive(Component, Default)]
pub struct SleepTimer(pub Timer);

The above works, nice! However, there's a problem. We only want mass properties for rigid bodies with the Dynamic marker component, as static and kinematic bodies shouldn't have any mass components. Additionally, SleepTimer is only relevant when the SleepPlugin is enabled, and only for dynamic rigid bodies. If sleeping is disabled, there's no reason to insert sleeping components for everything!

We could use component lifecycle hooks to add the components conditionally, but this is also problematic. It causes additional archetype moves for each insert command, and you can also only have one instance of each lifecycle hook per component, which could cause conflicts if the component already has a hook registered, either by itself or by another plugin. The next option is to use observers, but that feels less semantically correct, runs after hooks, and incurs a larger overhead.

Not being able to define component requirements externally and conditionally harms composability and usability. The natural way of organizing and encapsulating functionality in Bevy is a plugin, but they currently have no way of defining these relationships. Instead, all the requirements are forced to be on the type definition directly, which leaks implementation details they shouldn't always need to care about. This also hurts third party plugins, as they might want to make their own components required by external components, or even define their own versions of components that are required by some shared component.

Note

For the earlier example specifically, we could technically get around some of the issues with separate DynamicBody, KinematicBody, and StaticBody components that maybe require some shared RigidBodyMarker component. This is a broader issue though, and a lot of the same arguments still apply, especially around composability and third party requirements.

What solution would you like?

Add an add_require method to App. It takes two type arguments: a bundle defining the components that must be present, and the required components that should be added when they are present.

The earlier Mass case could be handled like this:

struct MassPropertyPlugin;

impl Plugin for MassPropertyPlugin {
    fn plugin(app: &mut App) {
        // Add `Mass` only for dynamic rigid bodies.
        // Both components must be present.
        app.add_require::<(RigidBody, Dynamic), Mass>();
    }
}

and the sleeping case like this:

struct SleepingPlugin;

impl Plugin for SleepingPlugin {
    fn plugin(app: &mut App) {
        // Add `SleepTimer` only for dynamic rigid bodies.
        app.add_require::<(RigidBody, Dynamic), SleepTimer>();
    }
}

The second type parameter could even be a bundle, letting you insert multiple components with a single requirement relationship.

Custom constructors can be provided using add_require_with:

app.add_require_with::<RigidBody, SleepTimer>(my_constructor);

These methods have a few nice benefits over the type-level require attribute:

  • In addition to "component requirements", we also have "bundle requirements". All components in a bundle must be present for the component to be inserted, allowing basic conditional insertion.
  • Requirements are optional and composable through plugins. The core RigidBody shouldn't need to care about sleeping unless the plugin for it is enabled.
  • Third party crates can define their own requirements for first party types. For example, "each Handle<Mesh> should require my custom rendering data components". This also gets around the orphan rule.
  • Users can replace built-in components with their own versions. Let's say GlobalTransform required Transform, but we instead wanted to use our own CustomTransform type. This isn't possible when the requirement is encoded at the type level. Instead, we might want to have a TransformPlugin that defines a relationship like app.add_require::<GlobalTransform, Transform>. This would still allow us to replace it with our own plugin that defines app.add_require::<GlobalTransform, CustomTransform>.
    • Note: This likely isn't how we should actually handle custom transforms. It's mainly just a simple example of how this could be useful in general.

Discussion

Breaking Assumptions? Removing Requirements?

Is this abstraction breaking, and does it invalidate assumptions made by other code? The suggestion to remove requirements was heavily controversial in #14927. Quoting Cart:

In general I think this [Required Component Overrides] is a pattern we should not be encouraging. If some third party SpecialComponent requires Transform, then that third party code (and other third parties that depend on it) can and should be written under the assumption that it will have a Transform.

A developer choosing to break that assumption risks breaking third party code in unexpected and arbitrary ways. And as these dependencies update, they risk adding new cases that also break.

I 100% agree with this sentiment. Being able to arbitrarily remove constraints like this from any plugin is risky and can easily break things.

However, requirements being additive and composable through plugins is not as big of an issue in my opinion. That requirement is semantically tied to the plugin, not the component, and you need to explicitly disable a plugin to remove its requirement constraints. This could indeed break logic that depends on that constraint, but the same is also true for systems and resources. Disabling a plugin just means that you are disabling some piece of functionality, and you can always reimplement it with your own version.

That's a big part of what makes Bevy great in my opinion. Plugins are organizational units that encapsulate and compose functionality in a clean and structured manner, and you can freely replace them with your own versions if you want to. I think this should extend to components to some extent.

To be clear, this shouldn't be the default way to define requirement constraints. The type-level require attribute should still be used in the vast majority of cases, especially for true requirements. For example, a RigidBody should always require a PhysicsTransform. However, for components that only make sense in the context of optional plugins (like SleepPlugin) or are defined in third party code, there should be an external way to define requirements.

QueryFilter

Instead of add_require taking a bundle, it could take a full QueryFilter. This would add the specified components only when the filter is satisfied.

However, I think this would quickly become confusing and technically challenging (impossible?), especially for things like Added<T> and Changed<T>.

The only useful functionality other than taking a bundle would be a way to define Without<T> filters, so you could do e.g. app.add_require::<(RigidBody, Without<Static>), Mass>, meaning that Mass is required by all rigid bodies that are not static.

What alternative(s) have you considered?

Keep inserting components manually using lifecycle hooks where possible, and using observers where it isn't.

I just want some form of:

  • conditional requirements, i.e. only requiring components if some other components are/aren't also present
  • allowing plugins to define component requirements.

What this looks like isn't as important to me, as long as it's possible. I don't think forcing the use of hooks or observers for this is a good solution, unless doing it through required components is impossible from a technical standpoint.

Metadata

Metadata

Assignees

No one assigned

    Labels

    A-ECSEntities, components, systems, and eventsC-FeatureA new feature, making something new possibleS-Ready-For-ImplementationThis issue is ready for an implementation PR. Go for it!X-BlessedHas a large architectural impact or tradeoffs, but the design has been endorsed by decision makersX-ContentiousThere are nontrivial implications that should be thought through

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions