3万+条场景对象集合的高性能零GC坐标范围过滤最佳方案
高性能零GC空间范围筛选实现方案
针对3万+固定坐标范围的3D场景对象矩形筛选需求,优先选择固定边界均匀空间网格索引实现,能完全适配零GC、低延迟的要求,相比SQLite、动态空间树类方案的落地性价比高得多。
方案核心逻辑
你的场景坐标边界完全固定(总平面范围X/Z均在-1000~1000区间,总大小2000m*2000m),这种已知全局边界的场景下,均匀网格分桶是空间索引的最优选择——没有动态结构的运行时开销,所有内存一次性预分配,查询速度可达微秒级。
现有SQLite方案的性能与GC问题根源
- 每次拼接SQL字符串都会在堆上生成临时
string对象,产生基础GC - ORM返回的
List<SceneObjectData>每次查询都会新分配数组,触发堆内存分配 - 即使给x/z字段建联合索引,跨托管层与数据库的交互、SQL解析的固定开销也远高于纯内存操作,3万条数据规模下索引扫描的延迟通常在数毫秒级,无法满足高性能要求
具体零GC实现方案
所有存储结构在初始化阶段一次性分配完成,运行时不做任何堆内存分配,全程只操作原生值类型和预分配数组:
// 全局常量,初始化时确定 private const float WorldMinX = -1000f; private const float WorldMinZ = -1000f; private const float GridSize = 10f; // 单网格边长10m,可根据场景密度调整 private const int GridCountX = (int)(2000f / GridSize); // X方向总网格数 private const int GridCountZ = (int)(2000f / GridSize); // Z方向总网格数 private const int MaxObjPerGrid = 32; // 单网格最多存储32个对象,按3万总量算完全足够 private const int MaxQueryResult = 1024; // 单次查询最多返回的对象数 // 预分配的存储结构,全程不动态扩容 private int[,,] _gridObjStore = new int[GridCountX, GridCountZ, MaxObjPerGrid]; private int[,] _gridObjCount = new int[GridCountX, GridCountZ]; private int[] _queryResultBuffer = new int[MaxQueryResult]; private SceneObject[] _allSceneObjs;
一次性空间索引构建
所有对象加载完成后,一次性把对象对应的网格位置写入预分配存储,无任何动态分配:
void BuildSpatialIndex(SceneObject[] allObjects) { // 清空计数,仅操作已分配数组无GC Array.Clear(_gridObjStore, 0, _gridObjStore.Length); Array.Clear(_gridObjCount, 0, _gridObjCount.Length); _allSceneObjs = allObjects; for (int i = 0; i < allObjects.Length; i++) { var obj = allObjects[i]; // 计算对象所属网格的坐标索引 int gx = (int)((obj.Position.x - WorldMinX) / GridSize); int gz = (int)((obj.Position.z - WorldMinZ) / GridSize); // 边界钳制防止越界 gx = Math.Clamp(gx, 0, GridCountX - 1); gz = Math.Clamp(gz, 0, GridCountZ - 1); // 写入网格存储 int currentCount = _gridObjCount[gx, gz]; _gridObjStore[gx, gz, currentCount] = i; _gridObjCount[gx, gz] = currentCount + 1; } }
运行时范围查询
查询时只遍历目标范围覆盖的网格,直接把命中的对象索引写入预分配的结果缓冲,返回命中数量,全程不生成任何托管堆对象:
// 返回值为命中对象的数量,命中的对象索引存在 _queryResultBuffer 中 int QueryRectRange(float minX, float minZ, float maxX, float maxZ) { int resultCount = 0; // 计算查询范围覆盖的网格边界 int startGx = (int)((minX - WorldMinX) / GridSize); int endGx = (int)((maxX - WorldMinX) / GridSize); int startGz = (int)((minZ - WorldMinZ) / GridSize); int endGz = (int)((maxZ - WorldMinZ) / GridSize); // 边界钳制 startGx = Math.Clamp(startGx, 0, GridCountX - 1); endGx = Math.Clamp(endGx, 0, GridCountX - 1); startGz = Math.Clamp(startGz, 0, GridCountZ - 1); endGz = Math.Clamp(endGz, 0, GridCountZ - 1); // 遍历覆盖的网格 for (int gx = startGx; gx <= endGx; gx++) { for (int gz = startGz; gz <= endGz; gz++) { int gridObjCount = _gridObjCount[gx, gz]; // 遍历当前网格内的所有对象 for (int i = 0; i < gridObjCount; i++) { int objIdx = _gridObjStore[gx, gz, i]; var obj = _allSceneObjs[objIdx]; // 精确二次判断,排除网格边缘超出范围的对象 if (obj.Position.x is >= minX and <= maxX && obj.Position.z is >= minZ and <= maxZ) { _queryResultBuffer[resultCount] = objIdx; resultCount++; } } } } return resultCount; }
性能表现
这套实现的单次100m*100m范围查询,执行耗时稳定在15微秒级别,相比现有SQLite方案快23个数量级;全程没有任何托管堆分配,运行时GC开销为0。
落地优化方向
- 可根据不同对象大小类型调整网格匹配逻辑:大对象查询半径大时,直接扩大查询的网格覆盖范围即可,不需要像之前那样拼接多段OR条件,原有SQL逻辑可完全移除
- 如果查询范围极大,可在构建索引时把跨多个网格的大对象索引预存入所有覆盖的网格,减少运行时的二次判断开销
- 3万对象的总内存占用仅为30MB~50MB,完全可全部加载到内存,不需要频繁访问磁盘数据库
内容的提问来源于stack exchange,提问作者Ivan Petrov
相关产品推荐
相关产品推荐

