-
Notifications
You must be signed in to change notification settings - Fork 144
Implement new custom ECS (WIP) #404
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
base: main
Are you sure you want to change the base?
Conversation
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Looks good, some minor questions.
Could we perhaps use FnvMap
for component storage since it is insanely fast. I know we are not using an ArchType based system, but my example is based on it.
struct World {
// Finding the `ArchTypeId` for a given set of components,
// used when spawning a new entity or adding a component to an existing entity.
// Note btree maps and sets might not be ideal, since they are kinda slow.
arch_type_sets: BTreeMap<BTreeSet<ComponentId>, ArchTypeId>,
// When quering we and all components bitset together,
// which yields indices into `arch_types`.
components: FnvHashMap<ComponentId, BitSet>,
arch_types: Vec<ArchType>,
// Mapping from entity id to memory location.
entities: FnvHashMap<EntityId, (ArchTypeId, ArchTypeOffset)>,
}
struct ArchType {
components: FnvHashMap<ComponentId, MemorySpace>,
}
Querying ~
- For each component get the
bitset
fromworld.components
- And all the bit sets together
- Each remaining true ArchTypeId in the bitset contains all the queried components.
- Get each component from each archtypes doring the iteration.
Insertion ~
- Create set of all component ids
- Try getting a archtype id from
world.arch_type_sets
, if it does not exist push a new archtype and insert the id. - Insert the components at the end of
arch_type.components
- Create a new entity id by inserting
(arch_type_id, offset)
Updating entity
- Swap remove from the archtype
- push to new archtype
- Fix entity id ptrs (this require a reverse lookup table, which I did not add)
quill/ecs/src/component.rs
Outdated
Self(ComponentTypeIdInner::Rust(TypeId::of::<T>())) | ||
} | ||
|
||
pub fn opaque(id: u64) -> Self { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Maybe 32 bits would be enough?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There's no harm in using a u64 so I disagree, there is only benefit.
quill/ecs/src/component.rs
Outdated
|
||
#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] | ||
enum ComponentTypeIdInner { | ||
Rust(TypeId), |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Maybe just use Opaque
and use a FnvHashMap
to map from type id to opaque id.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
How would that be better?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We have to chose a true id at one point, I suggest we chose Opaque
as the true id and map TypeId
to the true id(Opaque
). This indirection have happen at some point.
To my knowledge.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What do you mean by "true id" and why is it needed?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
"true id" one universal identifier for components, no distinction between local ie Any::TypeId
or remote ids.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
And why is it needed?
quill/ecs/src/storage/sparse_set.rs
Outdated
use super::blob_vec::BlobVec; | ||
|
||
/// Stores components in a sparse set. | ||
pub struct SparseSetStorage { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
FnvHashMap
is insanely fast, maybe it could be used in stead of SparseSetStorage
?
Might require some architectural changes, since for each entity you would lookup its components by their id.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm using sparse sets instead of hash tables because we can optimize query iteration by e.g. packing all entities matching a given archetype into the front of the array. See https://skypjack.github.io/2019-03-07-ecs-baf-part-2/
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Wouldn't using a HashMap defeat part of the purpose of ECS by getting rid of linear memory reads?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Sparse set already gets rid of 100% linear memory reads. Also FnvHashMap
is not just fast, its insanely fast... it even beats bitsets... its crazy.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I mean the memory reads with sparse sets are still linear, just with gaps.
Using a HashMap will make memory reads completely unpredictable.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hmm now I'm wondering where I saw next_power_of_two
being used. RawVec
uses the following algorithm
let required_cap = used_cap + needed_extra_cap;
let double_cap = self.cap * 2;
let new_cap = cmp::max(required_cap, double_cap);
Using next_power_of_two
~
let required_cap = used_cap + needed_extra_cap;
let new_cap = required_cap.next_power_of_two();
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
These two code snippets produce different behaviour. Also you are in the wrong comment thread xD
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Everything just seems to be going wrong for me at the moment 😅
Yea, the behavior is slightly different but both ensures push are O(n)
amortized. The implementation that does not use next_power_two
will double the capacity unless the total required cap is greater than the doubled size. Where the next_power_of_two
will double and if the capacity is greater than cap * 2
it will be cap * 2 * 2
it will repeat this pattern until double cap is equal or greater than required cap.
Changes BlobVec to BlobArray; it now has a fixed size. ComponentVec now provides the growable component vector, with the additional guarantee that items are stable in memory. Item drop functions are not yet called, so this has memory leaks. However, the code is sound and passes Miri.
Allocating with size 0 is UB. This uses a dangling pointer when the component size is 0. (Vec<T> does the same for zero-sized T.)
quill/ecs/src/ecs.rs
Outdated
/// is overriden. | ||
/// | ||
/// Time complexity: O(1) | ||
pub fn insert<T: Component>( |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Maybe return the old component?
This splits query lifetimes in two: 'w is the lifetime of the ECS and 'q is the lifetime of the query. Additionally, QueryDriver::iter now returns a struct instead of an impl Iterator. These changes allow GATs to be removed from the QueryDriver implementation.
It took some wrestling with lifetimes, but static queries now work. They have roughly the same interface as `hecs`.
I updated the PR with a checklist of remaining work. |
There was one false positive never_loop lint.
We previously uses World to store blocks and Ecs to store entities. Now, Level contains blocks and World contains entities. This change is motivated by most Rust ECS crates using World as their entity store. Also, Level is the terminology used internally in Minecraft from what I know.
Miri is slow. Spawning 10,000 entities takes several minutes.
Fixes leaked memory in components with allocations. All tests now pass with Miri.:
Amber came up with this on Discord. 'It's the Feather's backbone.'
Limitation: T must be Copy for soundness, since components are stored unaligned.
Plugins are temporarily disabled until we port Quill to the new ECS.
BUMP |
Bump |
Status
Description
Implements #402. Work in progress.
Progress
Drop
componentshecs
tests with Mirihecs
)quill
I'm going to hold off on the new event handling system and the bundles discussed in #402 for another PR.