Skip to content

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

Draft
wants to merge 22 commits into
base: main
Choose a base branch
from
Draft

Implement new custom ECS (WIP) #404

wants to merge 22 commits into from

Conversation

caelunshun
Copy link
Member

@caelunshun caelunshun commented Mar 13, 2021

Status

  • Ready
  • Development
  • Hold

Description

Implements #402. Work in progress.

Progress

  • Spawn and despawn entities
  • Random component access
  • Static and dynamic queries
  • Handle Drop components
  • Dynamic borrow checking and mutable component access
  • Pass all hecs tests with Miri
  • Move system executor to new ECS
  • Switch to the new ECS in Feather (should be trivial; the API is almost the same as hecs)
  • Integrate with quill

I'm going to hold off on the new event handling system and the bundles discussed in #402 for another PR.

@caelunshun caelunshun marked this pull request as draft March 13, 2021 05:10
Copy link
Member

@Defman Defman left a 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 ~

  1. For each component get the bitset from world.components
  2. And all the bit sets together
  3. Each remaining true ArchTypeId in the bitset contains all the queried components.
  4. Get each component from each archtypes doring the iteration.

Insertion ~

  1. Create set of all component ids
  2. Try getting a archtype id from world.arch_type_sets, if it does not exist push a new archtype and insert the id.
  3. Insert the components at the end of arch_type.components
  4. Create a new entity id by inserting (arch_type_id, offset)

Updating entity

  1. Swap remove from the archtype
  2. push to new archtype
  3. Fix entity id ptrs (this require a reverse lookup table, which I did not add)

Self(ComponentTypeIdInner::Rust(TypeId::of::<T>()))
}

pub fn opaque(id: u64) -> Self {
Copy link
Member

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?

Copy link
Contributor

@ambeeeeee ambeeeeee Mar 13, 2021

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.


#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
enum ComponentTypeIdInner {
Rust(TypeId),
Copy link
Member

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.

Copy link
Member

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?

Copy link
Member

@Defman Defman Mar 13, 2021

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.

Copy link
Member

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?

Copy link
Member

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.

Copy link
Member

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?

use super::blob_vec::BlobVec;

/// Stores components in a sparse set.
pub struct SparseSetStorage {
Copy link
Member

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.

Copy link
Member Author

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/

Copy link
Member

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?

Copy link
Member

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.

Copy link
Member

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.

Copy link
Member

@Defman Defman Mar 14, 2021

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();

Copy link
Member

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

Copy link
Member

@Defman Defman Mar 14, 2021

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.)
/// is overriden.
///
/// Time complexity: O(1)
pub fn insert<T: Component>(
Copy link
Member

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`.
@caelunshun
Copy link
Member Author

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.
@Defman
Copy link
Member

Defman commented Sep 6, 2021

BUMP

@Defman
Copy link
Member

Defman commented Sep 16, 2021

Bump

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants