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

Implements first draft of quadtree lookup implemented via *BoundingBox* #12

Merged

Conversation

DerLando
Copy link
Contributor

In the first draft for this I implemented the quadtree insertion via BoundingBox. I would have liked to implement the Spatial trait from aabb for Bounded but i could not get it to work without using Box<dyn Bounded>

It would probably make more sense to implement Spatial for DrawingObject as this is for now the main thing we want to query for.

@Michael-F-Bryan
Copy link
Owner

It would probably make more sense to implement Spatial for DrawingObject as this is for now the main thing we want to query for.

We can't add a DrawingObject to the quad tree directly because it's owned by the ECS storage, so you'll need to store an Entity.

I imagine the object we'd be adding to the quad tree would be a pair of a BoundingBox and an Entity.

struct Thing {
  bounds: BoundingBox,
  entity: Entity,
}

I would have liked to implement the Spatial trait from aabb for Bounded but i could not get it to work without using Box

Can you write something like this?v We define Bounded so the compiler shouldn't be complaining about the orphan rules. Can you show me the error message?

EDIT: I had another look. We're trying to say any object implementing our Bounded trait should also implement Spatial, but that'll fail because of the orphan rules. It'd be trivial for a 3rd party that depends on both aabb to have a manual implementation of Spatial, then when they pull in arcs and implement Bounded the compiler will get all confused ("coherence").

I don't think we'd be able to add that generic impl for Spatial, although if we aren't adding DrawingObjects to the quad tree we may not need it.

With this it is now possible to insert *Entities* from *World* into a quadtree
And query for them using *BoundingBox* queries
@DerLando
Copy link
Contributor Author

Using an intermediate SpatialEntity it is possible to insert entities in the quadtree 🙂

and keep track of changes to entities within it
@DerLando
Copy link
Contributor Author

Oof this commit got quite wordy

Would it make sense to implement an API similar to NameTable that lets us call

let space = world.read_resource::<SpaceTree>();
space.query_point();
space.query_region();

instead of the hacky methods on the system itself?

}
}

pub fn query_point(&self, pt: Vector) -> Option<Vec<Entity>> {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This should probably be possible to be called from somewhere else

@DerLando
Copy link
Contributor Author

DerLando commented Feb 1, 2020

I think I found a way around the problem of my branch being dependency-locked to

euclid = "0.19.8"

We should be able to reference different versions using this:
rust-lang/cargo#4953 (comment)

euclid = "0.20.7"
quadtree_euclid = { version = "0.19.8", registry = "custom", package = "euclid" }

@Michael-F-Bryan
Copy link
Owner

I'm about to land #13 which uses euclid (v0.20.7) types for everything. Is that going to break much for this PR?

@DerLando
Copy link
Contributor Author

DerLando commented Feb 1, 2020

It's going to break all the tests and some methods on Space, but it shouldn't be to hard to clean up those afterwards for me

@DerLando DerLando marked this pull request as ready for review February 1, 2020 12:13
Copy link
Owner

@Michael-F-Bryan Michael-F-Bryan left a comment

Choose a reason for hiding this comment

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

Thanks for getting the ball rolling on this.

After looking at the code I'm not overly satisfied with aabb-quadtree's API. It feels a bit restrictive and doesn't necessarily match the way I've needed to use these sorts of data structures in the past.

What did it feel like trying to wrap the QuadTree when writing Space?

fn default_tree() -> SpatialTree{
// Initialize quadtree
let size = BoundingBox::new(
Vector::new(-Self::WORLD_RADIUS, -Self::WORLD_RADIUS),
Copy link
Owner

Choose a reason for hiding this comment

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

I'm not a massive fan of telling the SpatialTree the size of the world up-front... Will the code panic if we choose the wrong number and try to place something outside of the space? And what happens if the space is a lot bigger than it needs to be, will we consume an unnecessary amount of memory?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

As i commented here, the trees bounding_box is hard-coded on initialization. If we try to insert something outside of those bounds afterwards it will panic.
I'm not a fan of how the aabb_quadtree handles this and I only see 3 options:

  • Check all insertions in our API and rebuild the tree with a big enough bounding box every time (seems really inefficient)
  • Allocate a very big world size (also inefficient)
  • Use another Quadtree implementation

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I did some more thinking on this:

  • Space needs to implement Default so we can use it as a Resource, this inevitely means we have to define some default starting parameters, as we can not supply arguments to Space::default().
  • Memory-wise we should still be quite efficient, as very large empty space in a quad-tree should take the same amount of memory as small, filled spaces
  • To be extra-safe I could write a wrapper around aabb_quadtree.insert() which detects objects outside the bounds of the tree-root and resize the tree if needed
  • Resizing basically means re-building the whole tree again, but with a large enough BoundingBox so the newly added SpatialEntity fits in

Copy link
Owner

Choose a reason for hiding this comment

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

Space needs to implement Default so we can use it as a Resource, this inevitely means we have to define some default starting parameters, as we can not supply arguments to Space::default().

Could we add Space as a resource in the bookkeeping system's setup() method then use ReadExpect when trying to access it?

Resizing basically means re-building the whole tree again, but with a large enough BoundingBox so the newly added SpatialEntity fits in

That's annoying.

I was kinda hoping you could implement a resize operation by taking the quad tree's "head" node and wrapping it in a larger node, almost like the tree equivalent of adding an item to the front of a linked list. You'd be left with a really unbalanced tree (the entire previous tree is in one quadrant of the tree's head node), but it turns a resize into an O(1) operation where we just copy some pointers around.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Could we add Space as a resource in the bookkeeping system's setup() method then use ReadExpect when trying to access it?

This seems like an unnecessary mix of two unrelated systems. So I'd rather stick to Space being it's own system

almost like the tree equivalent of adding an item to the front of a linked list.
There is nothing in the API that woudl allow for this.

It really looks like we need a better backend for our Space

).aabb();
let quadtree: SpatialTree = QuadTree::new(
size,
true,
Copy link
Owner

Choose a reason for hiding this comment

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

We should probably pull these out into constants, because true, 4, 16, 8, 4 doesn't really mean anything.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Done

}

pub fn insert(&mut self, spatial: SpatialEntity) {
let entity_id = spatial.entity.id();
Copy link
Owner

Choose a reason for hiding this comment

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

Why are we using id() here instead of the full Entity? An Entity also contains a Generation so you can't get the equivalent of a use-after-free when an entity gets deleted and specs allocates the same Index to an entity created later on.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Actually that makes more sense!

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Done.

let mut system = SpatialRelation::new(&world);
System::setup(&mut system, &mut world);

// make some changes after the initial setup
Copy link
Owner

Choose a reason for hiding this comment

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

As well as object creation, we may want to make sure object modification and deletion are detected too.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I'm a bit stuck on that part @Michael-F-Bryan ,

Similar as to the test for name_table_bookkeeping after we read a resource from world we can no longer borrow world as mutable.

let space = world.read_resource::<Space>(); // <- world is immutably borrowed here
world.delete_entity(first).unwrap(); // <- We would mutably borrow here

I can't seem to find a nice pattern/api for either dropping space or another way of letting us do queries but also still being able to manipulate the world afterwards.

How would you implement/test this for NameTable the implementation for Space should be similar

Copy link
Owner

Choose a reason for hiding this comment

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

Hmm... I guess you could fetch the space every time you need it instead of putting it in a local variable.

We should only need the space after the second system.run_now() when we do our assertions though, shouldn't we?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yes, never storing Space in a variable solves this limitation.

let space = world.read_resource::<Space>();

let query = space.query_point(Vector::new(3.0, -0.5));
assert!(query != None);
Copy link
Owner

Choose a reason for hiding this comment

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

This could be written as assert_ne!(query, None) or assert!(query.is_some()).

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Done.


let query = space.query_point(Vector::new(3.0, -0.5));
assert!(query != None);
assert_eq!(query.unwrap().len(), 1);
Copy link
Owner

Choose a reason for hiding this comment

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

Hmm... After seeing how a typical caller will be using the query, does it actually make sense to return an Option<Vec<_>>?

If we return an empty Vec (note: it's guaranteed that empty vecs don't allocate) when the query has no results, the query_*() methods should be a lot more ergonomic.

Also, semantically returning an empty Vec means your query completed but nothing was found. Which kinda makes sense?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

True that makes more sense!

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Done

@DerLando
Copy link
Contributor Author

DerLando commented Feb 3, 2020

I Should have taken care of all comments, some further thought:

  • The aabb_quadtree seems not mature enough for our use case, also there does not seem to be any crate we could use for this, so we will probaly need to implement a better tree at some point
  • I tried to structure Space in a way that makes it easy enough to replace the quadtree backend at a later stage
  • I think i found a bug inside of SyncBounds: bounds.remove() is never called inside of SyncBounds.run(), this led to some confusion in my testing of the removal of entities from Space. I will create a separate issue for this

@Michael-F-Bryan Michael-F-Bryan merged commit 44bf422 into Michael-F-Bryan:master Feb 4, 2020
@Michael-F-Bryan
Copy link
Owner

The aabb_quadtree seems not mature enough for our use case, also there does not seem to be any crate we could use for this, so we will probaly need to implement a better tree at some point

I agree with you there, but I'd like to see how far we can get without implementing our own. The longer we wait, the more we'll know what sort of API we want.

@Michael-F-Bryan
Copy link
Owner

I found an interesting article called A dive into spatial search algorithms.

They propose a nice way of retrieving query results in order...

An intuitive observation: when we search a particular set of boxes for K closest points, the boxes that are closer to the query point are more likely to have the points we look for. To use that to our advantage, we start our search at the top level by arranging the biggest boxes into a queue in the order from nearest to farthest:

Next, we “open” the nearest box, removing it from the queue and putting all its children (smaller boxes) back into the queue alongside the bigger ones:

We go on like that, opening the nearest box each time and putting its children back into the queue. When the nearest item removed from the queue is an actual point, it’s guaranteed to be the nearest point. The second point from the top of the queue will be second nearest, and so on.

This comes from the fact that all boxes we didn’t yet open only contain points that are farther than the distance to this box, so any points we pull from the queue will be nearer than points in any remaining boxes:

If our spatial tree is well balanced (meaning the branches are approximately the same size), we’ll only have to deal with a handful of boxes — leaving all the rest unopened during the search. This makes this algorithm extremely fast.

@DerLando
Copy link
Contributor Author

DerLando commented Feb 4, 2020

That is indeed an interesing query algorithm!

I wrote a toy quadtree alongside implementing aabb_quadtree to understand the data structure better, I should implement this query idea for it 🙂

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.

2 participants