Components as entities (v2)#24728
Conversation
…ents-as-entities-alt
Performance TestingI did some reasonably extensive performance testing. I ran both the entire ecs benchmark and several of our stress test examples. I've got the following data:
And the three examples I ran through were
ConclusionFrom the measurements I took I've come to the conclusion that this PR mostly keeps performance the same. At worst it degrades slightly (mostly based on the microbenchmarks). The best solution to fix the resource performance issue is likely still going to be #24058 or something like it. However! I still think this PR is worth merging, because
Important NoteWhen review and discussing this PR, I prefer if you mostly kept it anchored to some line of code (even if only vaguely related). This makes it easier to follow particular threads of argument. |
There was a problem hiding this comment.
Looks good! I don't believe the #24102 is necessary for this, and can be left for later.
With this change, it becomes possible to express uniqueness regarding ComponentId collections/iterators as well. There are now likely several sections of code where it would make sense to convert the previous ComponentId collection/iterator types to their EntitySet version. That would make for a good follow-up PR!
One place for which this is especially relevant is DynamicComponentFetch, the current dynamic way to access the Components of an entity disjointly.
This functionality should fall under similar design considerations as #18234, though that PR has gotten stalled.
chescock
left a comment
There was a problem hiding this comment.
Oh, nice! Reserving the Entity early and then using spawn_at is a really nice approach. I was worried this would be really complex, but it's very readable and even net negative lines!
I'm surprised that using HashMap for access didn't affect the benchmarks! It might be because all of the access checks are done at startup, while we mostly benchmark steady-state performance.
Most of my comments are just style nits, but I do think we should have a clear story for how users should spawn resource entities that have additional components, and we should try to ensure that ResMut<R> and Query<&mut R, Without<IsResource>> are sound.
| (true, true) => self_set.bitand_assign(other_set), | ||
| (true, false) => self_set.sub_assign(other_set), | ||
| (false, true) => { | ||
| *self_inverted = true; | ||
| self_set.difference_from(other_set); | ||
| *self_set = other_set.clone().sub(self_set); | ||
| } | ||
| (false, false) => self_set.union_with(other_set), | ||
| (false, false) => self_set.bitor_assign(other_set), |
There was a problem hiding this comment.
Since these are the operator traits in core::ops, would it be more clear to use the actual operators? (Here and elsewhere.)
| (true, true) => self_set.bitand_assign(other_set), | |
| (true, false) => self_set.sub_assign(other_set), | |
| (false, true) => { | |
| *self_inverted = true; | |
| self_set.difference_from(other_set); | |
| *self_set = other_set.clone().sub(self_set); | |
| } | |
| (false, false) => self_set.union_with(other_set), | |
| (false, false) => self_set.bitor_assign(other_set), | |
| (true, true) => *self_set &= other_set, | |
| (true, false) => *self_set -= other_set, | |
| (false, true) => { | |
| *self_inverted = true; | |
| *self_set = other_set - self_set; | |
| } | |
| (false, false) => *self_set |= other_set, |
(Oh, neat, GitHub finally lets me add a suggestion that crosses deleted lines! ... It does not render it very helpfully, though.)
There was a problem hiding this comment.
I personally prefer union_with, difference_with and intersect_with, as they more clearly communicate the idea of sets compared to bit operations. I did have to resort to using them for the non-in-place methods as the .difference method simply doesn't return the right type, but it should still be an improvement in readabillity.
| let resource = func(self); | ||
| move_as_ptr!(resource); | ||
| let entity_mut = self.spawn_with_caller(resource, caller); // ResourceCache is updated automatically | ||
| let entity_mut = self.spawn_at_with_caller(entity, resource, caller).unwrap(); |
There was a problem hiding this comment.
If someone despawns a resource, these spawn_at(...).unwrap() calls will panic, right? Ah, but that's what we do today in the IsResource hook, so that's no worse.
Do we want a "Resource entity {} of {} has been despawned, when it's not supposed to be." message in the panic, like we have in the IsResource hooks?
There was a problem hiding this comment.
In this specific case, it's impossible for the unwrap to panic, because at the top of the function it calls register_component, which allocates the entity. I've switched to spawn_at_unchecked and added an explanation in a comment.
I'll do the same for the other spawn_at(entity).unwrap() or, if they really could fail, throw an appropriate error.
| #[cfg_attr(feature = "bevy_reflect", derive(Reflect), reflect(Component, Debug))] | ||
| #[derive(Component, Debug)] | ||
| #[component(on_insert, on_discard, on_despawn)] | ||
| pub struct IsResource(ComponentId); |
There was a problem hiding this comment.
This would be for a follow-up and not this PR, but: Do we still need IsResource?
At the very least, we must not need it to store ComponentId, since that's always going to be equal to the Entity, right?
There was a problem hiding this comment.
This is a good question and depends quite a bit on where we want to have the logic that handles uniqueness. Right now, we need some hooks to ensure that a resource isn't spawned where it isn't supposed to be spawned.
If we put this logic on a component hook on every Resource (which I think makes sense), this is trivial. You just check if context.component_id matches context.entity.
But you only get one hook, and a user might want to use that hook for their own stuff. Using IsResource like this offloads this logic to another component and leaves room for user hooks on resources. However, the link between them is much weaker (what if you remove IsResource, but keep the resource? and other questions), and it would still need to keep a hold of ComponentId.
I think the former is a better solution, but we currently offer the latter, so changing this would be a regression. This is not major, since lifetime observers exist and you get an unlimited amount of them, but observers aren't quite as good as hooks in some cases.
| self.entity_mut(entity) | ||
| } else { | ||
| self.spawn_empty() | ||
| self.spawn_empty_at(entity).unwrap() |
There was a problem hiding this comment.
I think spawn_empty_at duplicates some of the checks from contains_spawned. This is also the code that got simplified a bit in the closed #24594 with get_or_spawn_resource_entity. I wonder if we want a method on World like
#[track_caller]
pub fn get_or_spawn_at(
&mut self,
entity: Entity,
) -> Result<EntityWorldMut<'_>, InvalidEntityError> {
match self.entities.get_spawned(entity) {
Ok(location) => Ok(unsafe { EntityWorldMut::new(self, entity, Some(location)) }),
Err(EntityNotSpawnedError::ValidButNotSpawned { .. }) => {
Ok(self.spawn_empty_at_unchecked(entity, MaybeLocation::caller()))
}
Err(EntityNotSpawnedError::Invalid(err)) => Err(err),
}
}There was a problem hiding this comment.
I think using spawn_empty_at_unchecked instead of spawn_empty_at is fine for now.
| schedule.add_systems(iter); | ||
| }); | ||
| add_archetypes(&mut world, archetype_count); | ||
| world.clear_entities(); |
There was a problem hiding this comment.
Is clear_entities() completely unusable now? Any resource that had been spawned can never be created again, right? Should we just remove it completely?
And, should this bench still despawn the entities that are spawned by add_archetypes? We could presumably add a marker component to identify them.
| self.components.resize_with(least_len, || None); | ||
| // SAFETY: The id has never been registered before. | ||
| unsafe { | ||
| self.components.insert_unique_unchecked(id, info); |
There was a problem hiding this comment.
How important is the perf from using insert_unique_unchecked here instead of an ordinary insert? I'd be inclined to use the safer version if we can.
There was a problem hiding this comment.
There is a genuine performance difference, and I think it'd be best to keep using it.
However, "never having been registered before" meaning "does not exist in this collection" should be more extensively documented somewhere.
There was a problem hiding this comment.
There is a genuine performance difference, and I think it'd be best to keep using it.
Does that difference matter for our case? The linked issue says it makes the insert 30% faster, but that's only a part of component registration, and I'm not sure we're even all that perf-sensitive for something that only runs once per component.
And thinking about it more, I'm not sure we can guarantee that the id is unique. A user can call world.entity_allocator_mut().free(entity) from safe code with the same Entity in a loop, and then register_component could allocate that same Entity for multiple components! It's fine to panic for something that bizarre, but I don't think it should allow UB.
Which also means we need to panic rather than silently overwrite if the key exists, so something like try_insert(id, info).expect(...).
| // We only expose `&ResourceCache` to code with access to a resource (such as `&World`), | ||
| // and that would conflict with the `DeferredWorld` passed to the resource hook. | ||
| unsafe { &mut *cache.0.get() }.insert(resource_component_id, context.entity); | ||
| if original_entity != context.entity { |
There was a problem hiding this comment.
Does this mean world.spawn((R, OtherComponents)) no longer works for adding a resource that also has other components? It needs to be something like
let entity = world.register_component::<R>().entity();
world.spawn_at(entity, (R, OtherComponents));?
That should go in the migration guide, but maybe we also need a convenience method like world.insert_resource_with(R, OtherComponents) or world.get_or_spawn_resource_entity::<R>().insert((R, OtherComponents)).
There was a problem hiding this comment.
Correct.
I'm hesitant to add methods like world.insert_resource_with, because currently bevy_world_serialize drops any extra components you add to a resource entity during serialization. I'd much prefer fixing that bug before adding convenience methods.
There was a problem hiding this comment.
This is the same reason why I didn't add required components in 0.19.
UPDATE: nevermind someone else did #24322, let's just hope no one notices before 0.20.
| pub unsafe fn get_resource_by_id(self, component_id: ComponentId) -> Option<Ptr<'w>> { | ||
| // SAFETY: We have permission to access the resource of `component_id`. | ||
| let entity = unsafe { self.resource_entities() }.get(component_id)?; | ||
| let entity = component_id.entity(); |
There was a problem hiding this comment.
How are we ensuring soundness for things like ResMut<R> and Query<&mut R, Without<IsResource>>?
Previously that was handled by IsResource being the source of truth for the resource_entities map, so if the IsResource component got removed then ResMut would no longer see the resource. But now we're going directly to the Entity, so I think it will be found by ResMut even if IsResource is missing and it will also match the Query.
There was a problem hiding this comment.
We are ensuring soundness just like we had before, since IsResource is still the source of truth, which is the only thing (together with the entity) queries care about.
Oh, wow, that's been stalled for more than a year. And I still don't feel motivated to pick it back up, so I'm just going to go mark it |
See #23988.
Change
The primary difference between this and #23988, is that this is not reliant on Entity Ranges (#24102). Because of this, there is a possibility for a performance regression with regards to
Components.componentsgoing from aSparseArraytoHashMap. There is an additional hash operation. Additionally, the fields onAccesshave changed from aFixedBitSetto aHashSet, so the set operations are likely slower.