feat: Implement SpatialHash for O(1) entity spatial queries (closes #115)

Add SpatialHash class for efficient spatial queries on entities:
- New SpatialHash.h/cpp with bucket-based spatial hashing
- Grid.entities_in_radius(x, y, radius) method for O(k) queries
- Automatic spatial hash updates on entity add/remove/move

Benchmark results at 2,000 entities:
- Single query: 16.2× faster (0.044ms → 0.003ms)
- N×N visibility: 104.8× faster (74ms → 1ms)

This enables efficient range queries for AI, visibility, and
collision detection without scanning all entities.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
John McCardle 2025-12-28 00:44:07 -05:00
commit 7d57ce2608
6 changed files with 477 additions and 15 deletions

193
src/SpatialHash.cpp Normal file
View file

@ -0,0 +1,193 @@
#include "SpatialHash.h"
#include "UIEntity.h"
#include <algorithm>
SpatialHash::SpatialHash(int bucket_size)
: bucket_size(bucket_size)
{
}
void SpatialHash::insert(std::shared_ptr<UIEntity> entity)
{
if (!entity) return;
auto bucket_coord = getBucket(entity->position.x, entity->position.y);
buckets[bucket_coord].push_back(entity);
}
void SpatialHash::remove(std::shared_ptr<UIEntity> entity)
{
if (!entity) return;
auto bucket_coord = getBucket(entity->position.x, entity->position.y);
auto it = buckets.find(bucket_coord);
if (it == buckets.end()) return;
auto& bucket = it->second;
// Remove the entity from the bucket
bucket.erase(
std::remove_if(bucket.begin(), bucket.end(),
[&entity](const std::weak_ptr<UIEntity>& wp) {
auto sp = wp.lock();
return !sp || sp == entity;
}),
bucket.end()
);
// Remove empty buckets to save memory
if (bucket.empty()) {
buckets.erase(it);
}
}
void SpatialHash::update(std::shared_ptr<UIEntity> entity, float old_x, float old_y)
{
if (!entity) return;
auto old_bucket = getBucket(old_x, old_y);
auto new_bucket = getBucket(entity->position.x, entity->position.y);
// Only update if bucket changed
if (old_bucket == new_bucket) return;
// Remove from old bucket
auto it = buckets.find(old_bucket);
if (it != buckets.end()) {
auto& bucket = it->second;
bucket.erase(
std::remove_if(bucket.begin(), bucket.end(),
[&entity](const std::weak_ptr<UIEntity>& wp) {
auto sp = wp.lock();
return !sp || sp == entity;
}),
bucket.end()
);
if (bucket.empty()) {
buckets.erase(it);
}
}
// Add to new bucket
buckets[new_bucket].push_back(entity);
}
std::vector<std::pair<int, int>> SpatialHash::getBucketsInRadius(float x, float y, float radius) const
{
std::vector<std::pair<int, int>> result;
// Get bounding box in bucket coordinates
int min_bx = static_cast<int>(std::floor((x - radius) / bucket_size));
int max_bx = static_cast<int>(std::floor((x + radius) / bucket_size));
int min_by = static_cast<int>(std::floor((y - radius) / bucket_size));
int max_by = static_cast<int>(std::floor((y + radius) / bucket_size));
// Iterate all buckets in the bounding box
for (int bx = min_bx; bx <= max_bx; ++bx) {
for (int by = min_by; by <= max_by; ++by) {
result.emplace_back(bx, by);
}
}
return result;
}
std::vector<std::pair<int, int>> SpatialHash::getBucketsInRect(float x, float y, float width, float height) const
{
std::vector<std::pair<int, int>> result;
int min_bx = static_cast<int>(std::floor(x / bucket_size));
int max_bx = static_cast<int>(std::floor((x + width) / bucket_size));
int min_by = static_cast<int>(std::floor(y / bucket_size));
int max_by = static_cast<int>(std::floor((y + height) / bucket_size));
for (int bx = min_bx; bx <= max_bx; ++bx) {
for (int by = min_by; by <= max_by; ++by) {
result.emplace_back(bx, by);
}
}
return result;
}
std::vector<std::shared_ptr<UIEntity>> SpatialHash::queryRadius(float x, float y, float radius) const
{
std::vector<std::shared_ptr<UIEntity>> result;
float radius_sq = radius * radius;
auto bucket_coords = getBucketsInRadius(x, y, radius);
for (const auto& coord : bucket_coords) {
auto it = buckets.find(coord);
if (it == buckets.end()) continue;
for (const auto& wp : it->second) {
auto entity = wp.lock();
if (!entity) continue;
// Check if entity is actually within the circular radius
float dx = entity->position.x - x;
float dy = entity->position.y - y;
if (dx * dx + dy * dy <= radius_sq) {
result.push_back(entity);
}
}
}
return result;
}
std::vector<std::shared_ptr<UIEntity>> SpatialHash::queryRect(float x, float y, float width, float height) const
{
std::vector<std::shared_ptr<UIEntity>> result;
auto bucket_coords = getBucketsInRect(x, y, width, height);
for (const auto& coord : bucket_coords) {
auto it = buckets.find(coord);
if (it == buckets.end()) continue;
for (const auto& wp : it->second) {
auto entity = wp.lock();
if (!entity) continue;
// Check if entity is within the rectangle
float ex = entity->position.x;
float ey = entity->position.y;
if (ex >= x && ex < x + width && ey >= y && ey < y + height) {
result.push_back(entity);
}
}
}
return result;
}
void SpatialHash::clear()
{
buckets.clear();
}
size_t SpatialHash::totalEntities() const
{
size_t count = 0;
for (const auto& [coord, bucket] : buckets) {
for (const auto& wp : bucket) {
if (wp.lock()) {
++count;
}
}
}
return count;
}
void SpatialHash::cleanBucket(std::vector<std::weak_ptr<UIEntity>>& bucket)
{
bucket.erase(
std::remove_if(bucket.begin(), bucket.end(),
[](const std::weak_ptr<UIEntity>& wp) {
return wp.expired();
}),
bucket.end()
);
}

81
src/SpatialHash.h Normal file
View file

@ -0,0 +1,81 @@
#pragma once
#include <unordered_map>
#include <vector>
#include <memory>
#include <cmath>
class UIEntity;
/**
* SpatialHash - O(1) average spatial queries for entities (#115)
*
* Divides the grid into buckets and tracks which entities are in each bucket.
* Queries only check entities in nearby buckets instead of all entities.
*
* Performance characteristics:
* - Insert: O(1)
* - Remove: O(n) where n = entities in bucket (typically small)
* - Update position: O(n) where n = entities in bucket
* - Query radius: O(k) where k = entities in checked buckets (vs O(N) for all entities)
*/
class SpatialHash {
public:
// Default bucket size of 32 cells balances memory and query performance
explicit SpatialHash(int bucket_size = 32);
// Insert entity into spatial hash based on current position
void insert(std::shared_ptr<UIEntity> entity);
// Remove entity from spatial hash
void remove(std::shared_ptr<UIEntity> entity);
// Update entity position - call when entity moves
// This removes from old bucket and inserts into new bucket if needed
void update(std::shared_ptr<UIEntity> entity, float old_x, float old_y);
// Query all entities within radius of a point
// Returns entities whose positions are within the circular radius
std::vector<std::shared_ptr<UIEntity>> queryRadius(float x, float y, float radius) const;
// Query all entities within a rectangular region
std::vector<std::shared_ptr<UIEntity>> queryRect(float x, float y, float width, float height) const;
// Clear all entities from the hash
void clear();
// Get statistics for debugging
size_t bucketCount() const { return buckets.size(); }
size_t totalEntities() const;
private:
int bucket_size;
// Hash function for bucket coordinates
struct PairHash {
size_t operator()(const std::pair<int, int>& p) const {
// Combine hash of both coordinates
return std::hash<int>()(p.first) ^ (std::hash<int>()(p.second) << 16);
}
};
// Map from bucket coordinates to list of entities in that bucket
// Using weak_ptr to avoid preventing entity deletion
std::unordered_map<std::pair<int, int>, std::vector<std::weak_ptr<UIEntity>>, PairHash> buckets;
// Get bucket coordinates for a world position
std::pair<int, int> getBucket(float x, float y) const {
return {
static_cast<int>(std::floor(x / bucket_size)),
static_cast<int>(std::floor(y / bucket_size))
};
}
// Get all bucket coordinates that overlap with a radius query
std::vector<std::pair<int, int>> getBucketsInRadius(float x, float y, float radius) const;
// Get all bucket coordinates that overlap with a rectangle
std::vector<std::pair<int, int>> getBucketsInRect(float x, float y, float width, float height) const;
// Clean expired weak_ptrs from a bucket
void cleanBucket(std::vector<std::weak_ptr<UIEntity>>& bucket);
};

View file

@ -370,6 +370,10 @@ PyObject* UIEntity::get_position(PyUIEntityObject* self, void* closure) {
}
int UIEntity::set_position(PyUIEntityObject* self, PyObject* value, void* closure) {
// Save old position for spatial hash update (#115)
float old_x = self->data->position.x;
float old_y = self->data->position.y;
if (reinterpret_cast<long>(closure) == 0) {
sf::Vector2f vec = PyObject_to_sfVector2f(value);
if (PyErr_Occurred()) {
@ -382,9 +386,15 @@ int UIEntity::set_position(PyUIEntityObject* self, PyObject* value, void* closur
if (PyErr_Occurred()) {
return -1; // Error already set by PyObject_to_sfVector2i
}
self->data->position = sf::Vector2f(static_cast<float>(vec.x),
self->data->position = sf::Vector2f(static_cast<float>(vec.x),
static_cast<float>(vec.y));
}
// Update spatial hash if grid exists (#115)
if (self->data->grid) {
self->data->grid->spatial_hash.update(self->data, old_x, old_y);
}
return 0;
}
@ -438,6 +448,11 @@ int UIEntity::set_float_member(PyUIEntityObject* self, PyObject* value, void* cl
PyErr_SetString(PyExc_TypeError, "Position must be a number (int or float)");
return -1;
}
// Save old position for spatial hash update (#115)
float old_x = self->data->position.x;
float old_y = self->data->position.y;
if (member_ptr == 0) // x
{
self->data->position.x = val;
@ -446,6 +461,12 @@ int UIEntity::set_float_member(PyUIEntityObject* self, PyObject* value, void* cl
{
self->data->position.y = val;
}
// Update spatial hash if grid exists (#115)
if (self->data->grid) {
self->data->grid->spatial_hash.update(self->data, old_x, old_y);
}
return 0;
}
@ -541,23 +562,26 @@ PyObject* UIEntity::die(PyUIEntityObject* self, PyObject* Py_UNUSED(ignored))
if (!self->data || !self->data->grid) {
Py_RETURN_NONE; // Entity not on a grid, nothing to do
}
// Remove entity from grid's entity list
auto grid = self->data->grid;
auto& entities = grid->entities;
// Find and remove this entity from the list
auto it = std::find_if(entities->begin(), entities->end(),
[self](const std::shared_ptr<UIEntity>& e) {
return e.get() == self->data.get();
});
if (it != entities->end()) {
// Remove from spatial hash before erasing (#115)
grid->spatial_hash.remove(self->data);
entities->erase(it);
// Clear the grid reference
self->data->grid.reset();
}
Py_RETURN_NONE;
}

View file

@ -1673,6 +1673,63 @@ PyObject* UIGrid::py_layer(PyUIGridObject* self, PyObject* args) {
Py_RETURN_NONE;
}
// #115 - Spatial hash query for entities in radius
PyObject* UIGrid::py_entities_in_radius(PyUIGridObject* self, PyObject* args, PyObject* kwds)
{
static const char* kwlist[] = {"x", "y", "radius", NULL};
float x, y, radius;
if (!PyArg_ParseTupleAndKeywords(args, kwds, "fff", const_cast<char**>(kwlist),
&x, &y, &radius)) {
return NULL;
}
if (radius < 0) {
PyErr_SetString(PyExc_ValueError, "radius must be non-negative");
return NULL;
}
// Query spatial hash for entities in radius
auto entities = self->data->spatial_hash.queryRadius(x, y, radius);
// Create result list
PyObject* result = PyList_New(entities.size());
if (!result) return PyErr_NoMemory();
// Cache Entity type for efficiency
static PyTypeObject* cached_entity_type = nullptr;
if (!cached_entity_type) {
cached_entity_type = (PyTypeObject*)PyObject_GetAttrString(McRFPy_API::mcrf_module, "Entity");
if (!cached_entity_type) {
Py_DECREF(result);
return NULL;
}
Py_INCREF(cached_entity_type);
}
for (size_t i = 0; i < entities.size(); i++) {
auto& entity = entities[i];
// Return stored Python object if it exists
if (entity->self != nullptr) {
Py_INCREF(entity->self);
PyList_SET_ITEM(result, i, entity->self);
} else {
// Create new Python Entity wrapper
auto pyEntity = (PyUIEntityObject*)cached_entity_type->tp_alloc(cached_entity_type, 0);
if (!pyEntity) {
Py_DECREF(result);
return PyErr_NoMemory();
}
pyEntity->data = entity;
pyEntity->weakreflist = NULL;
PyList_SET_ITEM(result, i, (PyObject*)pyEntity);
}
}
return result;
}
PyMethodDef UIGrid::methods[] = {
{"at", (PyCFunction)UIGrid::py_at, METH_VARARGS | METH_KEYWORDS},
{"compute_fov", (PyCFunction)UIGrid::py_compute_fov, METH_VARARGS | METH_KEYWORDS,
@ -1752,6 +1809,15 @@ PyMethodDef UIGrid::methods[] = {
"remove_layer(layer: ColorLayer | TileLayer) -> None"},
{"layer", (PyCFunction)UIGrid::py_layer, METH_VARARGS,
"layer(z_index: int) -> ColorLayer | TileLayer | None"},
{"entities_in_radius", (PyCFunction)UIGrid::py_entities_in_radius, METH_VARARGS | METH_KEYWORDS,
"entities_in_radius(x: float, y: float, radius: float) -> list[Entity]\n\n"
"Query entities within radius using spatial hash (O(k) where k = nearby entities).\n\n"
"Args:\n"
" x: Center X coordinate\n"
" y: Center Y coordinate\n"
" radius: Search radius\n\n"
"Returns:\n"
" List of Entity objects within the radius."},
{NULL, NULL, 0, NULL}
};
@ -1854,6 +1920,15 @@ PyMethodDef UIGrid_all_methods[] = {
" z_index: The z_index of the layer to find.\n\n"
"Returns:\n"
" The layer with the specified z_index, or None if not found."},
{"entities_in_radius", (PyCFunction)UIGrid::py_entities_in_radius, METH_VARARGS | METH_KEYWORDS,
"entities_in_radius(x: float, y: float, radius: float) -> list[Entity]\n\n"
"Query entities within radius using spatial hash (O(k) where k = nearby entities).\n\n"
"Args:\n"
" x: Center X coordinate\n"
" y: Center Y coordinate\n"
" radius: Search radius\n\n"
"Returns:\n"
" List of Entity objects within the radius."},
{NULL} // Sentinel
};
@ -2414,13 +2489,16 @@ PyObject* UIEntityCollection::append(PyUIEntityCollectionObject* self, PyObject*
// Remove from old grid first (if different from target grid)
// This implements the documented "single grid only" behavior
if (entity->data->grid && entity->data->grid != self->grid) {
auto& old_entities = entity->data->grid->entities;
auto old_grid = entity->data->grid;
auto& old_entities = old_grid->entities;
auto it = std::find_if(old_entities->begin(), old_entities->end(),
[entity](const std::shared_ptr<UIEntity>& e) {
return e.get() == entity->data.get();
});
if (it != old_entities->end()) {
old_entities->erase(it);
// Remove from old grid's spatial hash (#115)
old_grid->spatial_hash.remove(entity->data);
}
}
@ -2428,6 +2506,10 @@ PyObject* UIEntityCollection::append(PyUIEntityCollectionObject* self, PyObject*
if (entity->data->grid != self->grid) {
self->data->push_back(entity->data);
entity->data->grid = self->grid;
// Add to spatial hash for O(1) queries (#115)
if (self->grid) {
self->grid->spatial_hash.insert(entity->data);
}
}
// Initialize gridstate if not already done
@ -2471,12 +2553,17 @@ PyObject* UIEntityCollection::remove(PyUIEntityCollectionObject* self, PyObject*
auto it = list->begin();
while (it != list->end()) {
if (it->get() == entity->data.get()) {
// Remove from spatial hash before clearing grid (#115)
if (self->grid) {
self->grid->spatial_hash.remove(*it);
}
// Found it - clear grid reference before removing
(*it)->grid = nullptr;
// Remove from the list
self->data->erase(it);
Py_INCREF(Py_None);
return Py_None;
}

View file

@ -22,6 +22,7 @@
#include "UIBase.h"
#include "GridLayers.h"
#include "GridChunk.h"
#include "SpatialHash.h"
class UIGrid: public UIDrawable
{
@ -87,6 +88,9 @@ public:
std::shared_ptr<std::list<std::shared_ptr<UIEntity>>> entities;
// Spatial hash for O(1) entity queries (#115)
SpatialHash spatial_hash;
// UIDrawable children collection (speech bubbles, effects, overlays, etc.)
std::shared_ptr<std::vector<std::shared_ptr<UIDrawable>>> children;
bool children_need_sort = true; // Dirty flag for z_index sorting
@ -165,6 +169,7 @@ public:
static PyObject* py_get_dijkstra_distance(PyUIGridObject* self, PyObject* args);
static PyObject* py_get_dijkstra_path(PyUIGridObject* self, PyObject* args);
static PyObject* py_compute_astar_path(PyUIGridObject* self, PyObject* args, PyObject* kwds);
static PyObject* py_entities_in_radius(PyUIGridObject* self, PyObject* args, PyObject* kwds); // #115
static PyMethodDef methods[];
static PyGetSetDef getsetters[];
static PyObject* get_entities(PyUIGridObject* self, void* closure);