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:
parent
8f2407b518
commit
7d57ce2608
6 changed files with 477 additions and 15 deletions
193
src/SpatialHash.cpp
Normal file
193
src/SpatialHash.cpp
Normal 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
81
src/SpatialHash.h
Normal 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);
|
||||||
|
};
|
||||||
|
|
@ -370,6 +370,10 @@ PyObject* UIEntity::get_position(PyUIEntityObject* self, void* closure) {
|
||||||
}
|
}
|
||||||
|
|
||||||
int UIEntity::set_position(PyUIEntityObject* self, PyObject* value, 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) {
|
if (reinterpret_cast<long>(closure) == 0) {
|
||||||
sf::Vector2f vec = PyObject_to_sfVector2f(value);
|
sf::Vector2f vec = PyObject_to_sfVector2f(value);
|
||||||
if (PyErr_Occurred()) {
|
if (PyErr_Occurred()) {
|
||||||
|
|
@ -382,9 +386,15 @@ int UIEntity::set_position(PyUIEntityObject* self, PyObject* value, void* closur
|
||||||
if (PyErr_Occurred()) {
|
if (PyErr_Occurred()) {
|
||||||
return -1; // Error already set by PyObject_to_sfVector2i
|
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));
|
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;
|
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)");
|
PyErr_SetString(PyExc_TypeError, "Position must be a number (int or float)");
|
||||||
return -1;
|
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
|
if (member_ptr == 0) // x
|
||||||
{
|
{
|
||||||
self->data->position.x = val;
|
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;
|
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;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -541,23 +562,26 @@ PyObject* UIEntity::die(PyUIEntityObject* self, PyObject* Py_UNUSED(ignored))
|
||||||
if (!self->data || !self->data->grid) {
|
if (!self->data || !self->data->grid) {
|
||||||
Py_RETURN_NONE; // Entity not on a grid, nothing to do
|
Py_RETURN_NONE; // Entity not on a grid, nothing to do
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove entity from grid's entity list
|
// Remove entity from grid's entity list
|
||||||
auto grid = self->data->grid;
|
auto grid = self->data->grid;
|
||||||
auto& entities = grid->entities;
|
auto& entities = grid->entities;
|
||||||
|
|
||||||
// Find and remove this entity from the list
|
// Find and remove this entity from the list
|
||||||
auto it = std::find_if(entities->begin(), entities->end(),
|
auto it = std::find_if(entities->begin(), entities->end(),
|
||||||
[self](const std::shared_ptr<UIEntity>& e) {
|
[self](const std::shared_ptr<UIEntity>& e) {
|
||||||
return e.get() == self->data.get();
|
return e.get() == self->data.get();
|
||||||
});
|
});
|
||||||
|
|
||||||
if (it != entities->end()) {
|
if (it != entities->end()) {
|
||||||
|
// Remove from spatial hash before erasing (#115)
|
||||||
|
grid->spatial_hash.remove(self->data);
|
||||||
|
|
||||||
entities->erase(it);
|
entities->erase(it);
|
||||||
// Clear the grid reference
|
// Clear the grid reference
|
||||||
self->data->grid.reset();
|
self->data->grid.reset();
|
||||||
}
|
}
|
||||||
|
|
||||||
Py_RETURN_NONE;
|
Py_RETURN_NONE;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1673,6 +1673,63 @@ PyObject* UIGrid::py_layer(PyUIGridObject* self, PyObject* args) {
|
||||||
Py_RETURN_NONE;
|
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[] = {
|
PyMethodDef UIGrid::methods[] = {
|
||||||
{"at", (PyCFunction)UIGrid::py_at, METH_VARARGS | METH_KEYWORDS},
|
{"at", (PyCFunction)UIGrid::py_at, METH_VARARGS | METH_KEYWORDS},
|
||||||
{"compute_fov", (PyCFunction)UIGrid::py_compute_fov, 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"},
|
"remove_layer(layer: ColorLayer | TileLayer) -> None"},
|
||||||
{"layer", (PyCFunction)UIGrid::py_layer, METH_VARARGS,
|
{"layer", (PyCFunction)UIGrid::py_layer, METH_VARARGS,
|
||||||
"layer(z_index: int) -> ColorLayer | TileLayer | None"},
|
"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}
|
{NULL, NULL, 0, NULL}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -1854,6 +1920,15 @@ PyMethodDef UIGrid_all_methods[] = {
|
||||||
" z_index: The z_index of the layer to find.\n\n"
|
" z_index: The z_index of the layer to find.\n\n"
|
||||||
"Returns:\n"
|
"Returns:\n"
|
||||||
" The layer with the specified z_index, or None if not found."},
|
" 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
|
{NULL} // Sentinel
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -2414,13 +2489,16 @@ PyObject* UIEntityCollection::append(PyUIEntityCollectionObject* self, PyObject*
|
||||||
// Remove from old grid first (if different from target grid)
|
// Remove from old grid first (if different from target grid)
|
||||||
// This implements the documented "single grid only" behavior
|
// This implements the documented "single grid only" behavior
|
||||||
if (entity->data->grid && entity->data->grid != self->grid) {
|
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(),
|
auto it = std::find_if(old_entities->begin(), old_entities->end(),
|
||||||
[entity](const std::shared_ptr<UIEntity>& e) {
|
[entity](const std::shared_ptr<UIEntity>& e) {
|
||||||
return e.get() == entity->data.get();
|
return e.get() == entity->data.get();
|
||||||
});
|
});
|
||||||
if (it != old_entities->end()) {
|
if (it != old_entities->end()) {
|
||||||
old_entities->erase(it);
|
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) {
|
if (entity->data->grid != self->grid) {
|
||||||
self->data->push_back(entity->data);
|
self->data->push_back(entity->data);
|
||||||
entity->data->grid = self->grid;
|
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
|
// Initialize gridstate if not already done
|
||||||
|
|
@ -2471,12 +2553,17 @@ PyObject* UIEntityCollection::remove(PyUIEntityCollectionObject* self, PyObject*
|
||||||
auto it = list->begin();
|
auto it = list->begin();
|
||||||
while (it != list->end()) {
|
while (it != list->end()) {
|
||||||
if (it->get() == entity->data.get()) {
|
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
|
// Found it - clear grid reference before removing
|
||||||
(*it)->grid = nullptr;
|
(*it)->grid = nullptr;
|
||||||
|
|
||||||
// Remove from the list
|
// Remove from the list
|
||||||
self->data->erase(it);
|
self->data->erase(it);
|
||||||
|
|
||||||
Py_INCREF(Py_None);
|
Py_INCREF(Py_None);
|
||||||
return Py_None;
|
return Py_None;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@
|
||||||
#include "UIBase.h"
|
#include "UIBase.h"
|
||||||
#include "GridLayers.h"
|
#include "GridLayers.h"
|
||||||
#include "GridChunk.h"
|
#include "GridChunk.h"
|
||||||
|
#include "SpatialHash.h"
|
||||||
|
|
||||||
class UIGrid: public UIDrawable
|
class UIGrid: public UIDrawable
|
||||||
{
|
{
|
||||||
|
|
@ -87,6 +88,9 @@ public:
|
||||||
|
|
||||||
std::shared_ptr<std::list<std::shared_ptr<UIEntity>>> entities;
|
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.)
|
// UIDrawable children collection (speech bubbles, effects, overlays, etc.)
|
||||||
std::shared_ptr<std::vector<std::shared_ptr<UIDrawable>>> children;
|
std::shared_ptr<std::vector<std::shared_ptr<UIDrawable>>> children;
|
||||||
bool children_need_sort = true; // Dirty flag for z_index sorting
|
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_distance(PyUIGridObject* self, PyObject* args);
|
||||||
static PyObject* py_get_dijkstra_path(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_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 PyMethodDef methods[];
|
||||||
static PyGetSetDef getsetters[];
|
static PyGetSetDef getsetters[];
|
||||||
static PyObject* get_entities(PyUIGridObject* self, void* closure);
|
static PyObject* get_entities(PyUIGridObject* self, void* closure);
|
||||||
|
|
|
||||||
|
|
@ -138,6 +138,16 @@ def benchmark_range_query(entity, radius):
|
||||||
return elapsed * 1000, len(visible) # ms, count
|
return elapsed * 1000, len(visible) # ms, count
|
||||||
|
|
||||||
|
|
||||||
|
def benchmark_range_query_spatial(grid, x, y, radius):
|
||||||
|
"""B3b: Measure grid.entities_in_radius call (SpatialHash O(k) implementation)."""
|
||||||
|
start = time.perf_counter()
|
||||||
|
|
||||||
|
visible = grid.entities_in_radius(x, y, radius)
|
||||||
|
|
||||||
|
elapsed = time.perf_counter() - start
|
||||||
|
return elapsed * 1000, len(visible) # ms, count
|
||||||
|
|
||||||
|
|
||||||
def benchmark_n_to_n_visibility(grid, radius, sample_size):
|
def benchmark_n_to_n_visibility(grid, radius, sample_size):
|
||||||
"""B4: Measure visibility queries for a sample of entities.
|
"""B4: Measure visibility queries for a sample of entities.
|
||||||
|
|
||||||
|
|
@ -162,6 +172,29 @@ def benchmark_n_to_n_visibility(grid, radius, sample_size):
|
||||||
return elapsed * 1000, actual_sample, avg_visible # ms, sample_size, avg_found
|
return elapsed * 1000, actual_sample, avg_visible # ms, sample_size, avg_found
|
||||||
|
|
||||||
|
|
||||||
|
def benchmark_n_to_n_visibility_spatial(grid, radius, sample_size):
|
||||||
|
"""B4b: Measure N×N visibility using SpatialHash.
|
||||||
|
|
||||||
|
Same test as B4 but uses grid.entities_in_radius() instead of entity.visible_entities().
|
||||||
|
"""
|
||||||
|
entities_list = list(grid.entities)
|
||||||
|
n = len(entities_list)
|
||||||
|
actual_sample = min(sample_size, n)
|
||||||
|
sample = random.sample(entities_list, actual_sample)
|
||||||
|
|
||||||
|
start = time.perf_counter()
|
||||||
|
|
||||||
|
total_visible = 0
|
||||||
|
for entity in sample:
|
||||||
|
visible = grid.entities_in_radius(entity.x, entity.y, radius)
|
||||||
|
total_visible += len(visible)
|
||||||
|
|
||||||
|
elapsed = time.perf_counter() - start
|
||||||
|
avg_visible = total_visible / actual_sample if actual_sample > 0 else 0
|
||||||
|
|
||||||
|
return elapsed * 1000, actual_sample, avg_visible # ms, sample_size, avg_found
|
||||||
|
|
||||||
|
|
||||||
def benchmark_movement(grid, move_percent):
|
def benchmark_movement(grid, move_percent):
|
||||||
"""B5: Move a percentage of entities to random positions.
|
"""B5: Move a percentage of entities to random positions.
|
||||||
|
|
||||||
|
|
@ -232,8 +265,19 @@ def run_single_scale(n_entities):
|
||||||
print(f" Found: {found} entities in range")
|
print(f" Found: {found} entities in range")
|
||||||
print(f" Checked: {n_entities} entities (O(n) scan)")
|
print(f" Checked: {n_entities} entities (O(n) scan)")
|
||||||
|
|
||||||
|
# B3b: SpatialHash range query
|
||||||
|
print(f"\n[B3b] SpatialHash Range Query (radius={QUERY_RADIUS})...")
|
||||||
|
spatial_query_ms, spatial_found = benchmark_range_query_spatial(
|
||||||
|
grid, test_entity.x, test_entity.y, QUERY_RADIUS
|
||||||
|
)
|
||||||
|
print(f" Time: {spatial_query_ms:.3f}ms")
|
||||||
|
print(f" Found: {spatial_found} entities in range")
|
||||||
|
if query_ms > 0:
|
||||||
|
speedup = query_ms / spatial_query_ms if spatial_query_ms > 0 else float('inf')
|
||||||
|
print(f" Speedup: {speedup:.1f}× faster than O(n) scan")
|
||||||
|
|
||||||
# B4: N-to-N visibility
|
# B4: N-to-N visibility
|
||||||
print(f"\n[B4] N×N Visibility (sample={N2N_SAMPLE_SIZE})...")
|
print(f"\n[B4] N×N Visibility O(n) (sample={N2N_SAMPLE_SIZE})...")
|
||||||
n2n_ms, sample_size, avg_visible = benchmark_n_to_n_visibility(
|
n2n_ms, sample_size, avg_visible = benchmark_n_to_n_visibility(
|
||||||
grid, QUERY_RADIUS, N2N_SAMPLE_SIZE
|
grid, QUERY_RADIUS, N2N_SAMPLE_SIZE
|
||||||
)
|
)
|
||||||
|
|
@ -246,6 +290,20 @@ def run_single_scale(n_entities):
|
||||||
full_n2n_ms = per_query_ms * n_entities
|
full_n2n_ms = per_query_ms * n_entities
|
||||||
print(f" Estimated full N×N: {full_n2n_ms:,.0f}ms ({full_n2n_ms/1000:.1f}s)")
|
print(f" Estimated full N×N: {full_n2n_ms:,.0f}ms ({full_n2n_ms/1000:.1f}s)")
|
||||||
|
|
||||||
|
# B4b: N-to-N visibility with SpatialHash
|
||||||
|
print(f"\n[B4b] N×N Visibility SpatialHash (sample={N2N_SAMPLE_SIZE})...")
|
||||||
|
n2n_spatial_ms, _, _ = benchmark_n_to_n_visibility_spatial(
|
||||||
|
grid, QUERY_RADIUS, N2N_SAMPLE_SIZE
|
||||||
|
)
|
||||||
|
per_query_spatial_ms = n2n_spatial_ms / sample_size if sample_size > 0 else 0
|
||||||
|
print(f" Sample time: {n2n_spatial_ms:.2f}ms ({sample_size} queries)")
|
||||||
|
print(f" Per query: {per_query_spatial_ms:.3f}ms")
|
||||||
|
full_n2n_spatial_ms = per_query_spatial_ms * n_entities
|
||||||
|
print(f" Estimated full N×N: {full_n2n_spatial_ms:,.0f}ms ({full_n2n_spatial_ms/1000:.1f}s)")
|
||||||
|
if n2n_ms > 0:
|
||||||
|
n2n_speedup = n2n_ms / n2n_spatial_ms if n2n_spatial_ms > 0 else float('inf')
|
||||||
|
print(f" Speedup: {n2n_speedup:.1f}× faster than O(n)")
|
||||||
|
|
||||||
# B5: Movement
|
# B5: Movement
|
||||||
print(f"\n[B5] Movement ({MOVEMENT_PERCENT*100:.0f}% of entities)...")
|
print(f"\n[B5] Movement ({MOVEMENT_PERCENT*100:.0f}% of entities)...")
|
||||||
move_ms, moved = benchmark_movement(grid, MOVEMENT_PERCENT)
|
move_ms, moved = benchmark_movement(grid, MOVEMENT_PERCENT)
|
||||||
|
|
@ -261,10 +319,14 @@ def run_single_scale(n_entities):
|
||||||
'iter_coll_ms': iter_coll_ms,
|
'iter_coll_ms': iter_coll_ms,
|
||||||
'query_ms': query_ms,
|
'query_ms': query_ms,
|
||||||
'query_found': found,
|
'query_found': found,
|
||||||
|
'spatial_query_ms': spatial_query_ms,
|
||||||
|
'spatial_query_found': spatial_found,
|
||||||
'n2n_sample_ms': n2n_ms,
|
'n2n_sample_ms': n2n_ms,
|
||||||
'n2n_per_query_ms': per_query_ms,
|
'n2n_per_query_ms': per_query_ms,
|
||||||
'n2n_avg_visible': avg_visible,
|
'n2n_avg_visible': avg_visible,
|
||||||
'n2n_full_estimate_ms': full_n2n_ms,
|
'n2n_full_estimate_ms': full_n2n_ms,
|
||||||
|
'n2n_spatial_ms': n2n_spatial_ms,
|
||||||
|
'n2n_spatial_full_estimate_ms': full_n2n_spatial_ms,
|
||||||
'move_ms': move_ms,
|
'move_ms': move_ms,
|
||||||
'move_count': moved,
|
'move_count': moved,
|
||||||
}
|
}
|
||||||
|
|
@ -272,19 +334,29 @@ def run_single_scale(n_entities):
|
||||||
|
|
||||||
def print_summary_table():
|
def print_summary_table():
|
||||||
"""Print a summary table of all results."""
|
"""Print a summary table of all results."""
|
||||||
print("\n" + "=" * 80)
|
print("\n" + "=" * 100)
|
||||||
print("SUMMARY TABLE")
|
print("SUMMARY TABLE")
|
||||||
print("=" * 80)
|
print("=" * 100)
|
||||||
|
|
||||||
header = f"{'Entities':>10} {'Create':>10} {'Iterate':>10} {'Query':>10} {'N×N Est':>12} {'Move':>10}"
|
header = f"{'Entities':>10} {'Create':>10} {'Iterate':>10} {'Query O(n)':>12} {'Query Hash':>12} {'N×N O(n)':>12} {'N×N Hash':>12}"
|
||||||
print(header)
|
print(header)
|
||||||
print(f"{'':>10} {'(ms)':>10} {'(ms)':>10} {'(ms)':>10} {'(ms)':>12} {'(ms)':>10}")
|
print(f"{'':>10} {'(ms)':>10} {'(ms)':>10} {'(ms)':>12} {'(ms)':>12} {'(ms)':>12} {'(ms)':>12}")
|
||||||
print("-" * 80)
|
print("-" * 100)
|
||||||
|
|
||||||
for n in sorted(results.keys()):
|
for n in sorted(results.keys()):
|
||||||
r = results[n]
|
r = results[n]
|
||||||
|
speedup_q = r['query_ms'] / r['spatial_query_ms'] if r['spatial_query_ms'] > 0 else 0
|
||||||
|
speedup_n = r['n2n_full_estimate_ms'] / r['n2n_spatial_full_estimate_ms'] if r['n2n_spatial_full_estimate_ms'] > 0 else 0
|
||||||
print(f"{r['n']:>10,} {r['create_ms']:>10.1f} {r['iter_ms']:>10.2f} "
|
print(f"{r['n']:>10,} {r['create_ms']:>10.1f} {r['iter_ms']:>10.2f} "
|
||||||
f"{r['query_ms']:>10.2f} {r['n2n_full_estimate_ms']:>12,.0f} {r['move_ms']:>10.2f}")
|
f"{r['query_ms']:>12.3f} {r['spatial_query_ms']:>12.3f} "
|
||||||
|
f"{r['n2n_full_estimate_ms']:>12,.0f} {r['n2n_spatial_full_estimate_ms']:>12,.0f}")
|
||||||
|
|
||||||
|
print("\nSpatialHash Speedups:")
|
||||||
|
for n in sorted(results.keys()):
|
||||||
|
r = results[n]
|
||||||
|
speedup_q = r['query_ms'] / r['spatial_query_ms'] if r['spatial_query_ms'] > 0 else float('inf')
|
||||||
|
speedup_n = r['n2n_full_estimate_ms'] / r['n2n_spatial_full_estimate_ms'] if r['n2n_spatial_full_estimate_ms'] > 0 else float('inf')
|
||||||
|
print(f" {r['n']:>6,} entities: Query {speedup_q:>5.1f}×, N×N {speedup_n:>5.1f}×")
|
||||||
|
|
||||||
|
|
||||||
def print_analysis():
|
def print_analysis():
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue