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

Dynamic Component Id type #12794

Closed
wants to merge 11 commits into from
Closed

Conversation

ecoskey
Copy link
Contributor

@ecoskey ecoskey commented Mar 30, 2024

Objective

Currently working with dynamic component ids requires a lot of manual pointer work and unsafe code, which seems unnecessary when dynamic components are registered from known rust code.

Solution

Add a wrapper around ComponentId with a type parameter T to act as a witness that that id corresponds to a component with type T. This allows registering multiple components with the same underlying type, that dynamic queries can access separately in a safe way.


Changelog

  • added TypedComponentId
  • added world_id() to all entity cells
  • added get_dynamic(), get_dynamic_mut(), into_dynamic_mut(), insert_dynamic() as appropriate to entity cells
  • added init_dynamic_component() to World
  • added new_dynamic() to ComponentDescriptor

ecoskey added 4 commits March 29, 2024 22:09
note: name needs work

- added TypedComponentId
- added world_id() to all entity cells
- added get_dynamic(), get_dynamic_mut(), into_dynamic_mut(), insert_dynamic() as appropriate to entity cells
- added init_dynamic_component() to World
- added new_dynamic() to ComponentDescriptor
thank you CI tests for telling me I am dum
@jnhyatt
Copy link
Contributor

jnhyatt commented Mar 30, 2024

Worth considering, in my prototype I restrict T: Component which lets me choose the storage type from the component without having to specify it manually when creating a new dynamic component. It's such an often overlooked detail it feels weird to me to make people specify it. On the other hand there's definitely value to being able to use any type for the layout, but on the other hand you can always newtype. Restricting T: Component also has the benefit of being more explicit imo.

@ecoskey
Copy link
Contributor Author

ecoskey commented Mar 30, 2024

At least for me one of the key benefits of dynamic components is that they don't require newtyping and new variants can be made on the fly, hence the lack of a Component constraint.

Copy link
Contributor

@SkiFire13 SkiFire13 left a comment

Choose a reason for hiding this comment

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

The approach looks good to me. Some things that I would also like to see:

  • some tests, both for the happy path and for when the component_id refers to a different world or a component that doesn't exist;
  • some examples on how to use this together with dynamic queries (it would be very nice if we could remove the unsafe from the ecs/dynamic example);
  • an API that works with untyped ComponentIds too (right now you have to carry T everywhere, which might not be wanted in some cases):
    • for example this could be done with a method that upgrades a ComponentId to a TypedComponentId<T> by checking if the registered TypeId is T's.

crates/bevy_ecs/src/world/entity_ref.rs Outdated Show resolved Hide resolved
crates/bevy_ecs/src/world/entity_ref.rs Outdated Show resolved Hide resolved
crates/bevy_ecs/src/world/entity_ref.rs Outdated Show resolved Hide resolved
Comment on lines +271 to +274
pub fn init_dynamic_component<T: Send + Sync + 'static>(
&mut self,
storage_type: StorageType,
) -> TypedComponentId<T> {
Copy link
Contributor

Choose a reason for hiding this comment

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

Maybe you could have a init_dynamic_component that takes a T: Component and a init_dynamic_component_with_storage that only takes T: Send + Sync + 'static but requires specifying the storage?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

the first just sounds like init_component. Should that just return the typed wrapper then, since the inner id is still accessible?

Copy link
Contributor

Choose a reason for hiding this comment

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

Changing init_component to return TypedComponent<T> might be controversial, although consistent with this change.

Comment on lines +870 to +872
if self.world_id() != component_id.world_id {
panic!("Attempted to insert a dynamic component into the wrong World");
}
Copy link
Contributor

Choose a reason for hiding this comment

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

Just to be sure I would put a debug_assert! after all the world id checks that verifies that the type id of T is the same as the one in the ComponentDescriptor associated to component_id in this World.

debug_assert_eq!(self.components().get_info(component_id.id).and_then(|d| d.type_id()), Some(TypeId::of::<T>()));

Copy link
Contributor Author

Choose a reason for hiding this comment

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

good idea.

Comment on lines +404 to +408
pub struct TypedComponentId<T: Send + Sync + 'static> {
pub(crate) id: ComponentId, //todo: pub(crate) fields and have the World construct this, or private fields and construct from an &mut World reference?
pub(crate) world_id: WorldId,
pub(crate) data: PhantomData<T>,
}
Copy link
Contributor

Choose a reason for hiding this comment

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

This should document that a safety invariant of this type is that the world with id self.world_id contains a component with id self.id of type T.

@SkiFire13
Copy link
Contributor

At least for me one of the key benefits of dynamic components is that they don't require newtyping and new variants can be made on the fly, hence the lack of a Component constraint.

I wonder how much more ergonomic that is though, since you have to carry the TypedComponentId<T> everywhere to use them.

Also note that you won't need a newtype for each dynamic component. With a single newtype around e.g. a String you can create as many dynamic components that contain String as you want (as opposed to right now where you need a newtype per-component).

@mnmaita mnmaita added C-Feature A new feature, making something new possible A-ECS Entities, components, systems, and events labels Mar 30, 2024
@ecoskey
Copy link
Contributor Author

ecoskey commented Mar 30, 2024

Also note that you won't need a newtype for each dynamic component. With a single newtype around e.g. a String you can create as many dynamic components that contain String as you want (as opposed to right now where you need a newtype per-component).

Taking that to the extreme, you would probably just end up with TableStorageComponent<> and SparseStorageComponent<> newtypes, which should probably just end up as parameters anyway. Unless I'm misunderstanding you (good chance of that tbh). In my experience dynamic components do require the id being passed around, but I don't see a good way to avoid that.

Edit: just saw your "upgrading" idea. Seems useful, but I don't think it's a full replacement since you'd still have to pass the id around, or use ComponentIdOf, which will ignore any dynamic components.

@ecoskey
Copy link
Contributor Author

ecoskey commented Mar 31, 2024

todo:

  • tests
  • fix examples/dynamic.rs
  • link to dynamic.rs from try_insert_dynamic and insert_dynamic doc comment

@james7132
Copy link
Member

What is the expected use case here? Untyped components were originally introduced as a way to enable scripting interfaces, but here you have a Rust type. Can you provide a end-user or library use case that requires what are effectively runtime-generated newtype components?

@SkiFire13
Copy link
Contributor

Untyped components were originally introduced as a way to enable scripting interfaces, but here you have a Rust type.

For example if you're implementing scripting using an interpreted language this would let you store multiple components that hold a your_scripting_api::Value

@james-j-obrien
Copy link
Contributor

james-j-obrien commented Apr 1, 2024

Can you provide a end-user or library use case that requires what are effectively runtime-generated newtype components?

I feel in many use cases for dynamic components we actually do have a known type we want to treat the component as when we access it. As mentioned above many scripting use cases will be defining many of it's components as a shared Value type, but also if you had a simple "Counter" system in a card game where there was a runtime defined set of counters you could put on a certain card entity and you want to be able to query for cards with a given counter on them it could be done entirely safely with an API like this. I think removing the unsafe barrier in as many cases is possible makes it more likely people will actually make use of those features.

@james-j-obrien
Copy link
Contributor

My question for this implementation is if we have to check the world id anyway to ensure safety would it be worth it to drop the type wrapper all together and just check whether the type id matches the one you registered it with? That way you can just use component ids everywhere.

@urben1680
Copy link
Contributor

urben1680 commented Apr 6, 2024

What is the expected use case here? Untyped components were originally introduced as a way to enable scripting interfaces, but here you have a Rust type. Can you provide a end-user or library use case that requires what are effectively runtime-generated newtype components?

The author linked this to me when I was asking on the Discord for having dynamic marker types. And I am sold on this as a solution for what I want to do.

My library offers a few SystemParams with non-pub Queries in them. Part of the logic requires marker components. But it is impossible for such a component to be unique to every usage of my SystemParam. So I want to use this feature here to newtype () (or maybe non-ZST types) and use it as that unique marker type.

It is still not easy for the SystemParam to learn what the ComponentId of the marker type ended up being. But when my SystemParam initializes it's params, my marker type will have put it's ComponentId in a resource, discoverable by the following logic of the SystemParams init. Once the param knows the ComponentId, it can create Commands that adds and removes them.

Still a bit hacky, probably not what the most obvious use case could be here, but a use case only possible with this.

@jnhyatt
Copy link
Contributor

jnhyatt commented Apr 20, 2024

FYI, I ended up publishing my MVP as bevy_dyn_component. The proposed API is a bit different from this proposal, but has many similarities. The examples highlight one or two potential use cases for this as well, but I think most of them would ultimately be better as relations.

@ecoskey
Copy link
Contributor Author

ecoskey commented Aug 28, 2024

closing, was intended to solve a very niche issue that doesn't exist anymore (at least for me)

@ecoskey ecoskey closed this Aug 28, 2024
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
A-ECS Entities, components, systems, and events C-Feature A new feature, making something new possible
Projects
None yet
Development

Successfully merging this pull request may close these issues.

7 participants