03.10.2026 22 мин чтения Просмотров:10

Spatial Hash в Unity: быстрый поиск соседей без Physics.OverlapSphere

Spatial Hash ускоряет поиск ближайших объектов там, где Physics.OverlapSphere и полный перебор начинают съедать кадр. Разбираем хэш-сетку, вставку, запрос по радиусу и бенчмарк на C#.

Spatial Hash в Unity: быстрый поиск соседей без Physics.OverlapSphere

Когда в сцене накапливаются сотни или тысячи агентов, первый заметный провал производительности часто приходит не от рендера, а от логики соседства. Толпа NPC ищет ближайших противников, спавнер проверяет свободные точки, снаряд ищет цель в радиусе, а система симуляции пытается понять, кто рядом и кого можно затронуть. В таких задачах очень легко дойти до Physics.OverlapSphere, потому что API удобный и знакомый. Но на частом вызове, особенно в кадре, этот путь быстро начинает стоить дороже, чем кажется.

Есть альтернативы, которые не требуют физического мира и коллайдеров. Одна из самых практичных для игр и симуляций на C# в Unity - spatial hash, или хэш-сетка по пространству. Идея проста: пространство делится на ячейки, а объекты складываются в список по ключу ячейки. Затем поиск соседей сводится не к перебору всех объектов, а к проверке нескольких соседних ячеек. Для многих систем это даёт порядок выигрыша по времени, особенно когда плотность объектов умеренная и радиус поиска сравнительно мал.

В Unity такой подход полезен не только вместо Physics.OverlapSphere, но и вместо наивного полного перебора по списку. Он хорошо ложится на ИИ, crowd simulation, спавн, сбор ресурсов, наведение оружия, игровую экономику с зонами влияния. В связке с другими материалами каталога и статей сайта, например Steering Behaviors в Unity или Flow Field в Unity, spatial hash часто становится базовым слоем, который позволяет держать большое количество объектов без дорогих физических запросов.

Почему полный перебор и Physics.OverlapSphere быстро упираются в потолок

Полный перебор по массиву объектов выглядит безобидно, пока список не начинает расти. Если каждый NPC проверяет расстояние до каждого потенциального соседа, сложность одного кадра превращается в O(n^2). При 200 объектах это уже 40 000 проверок расстояния на один проход, а если таких систем несколько, количество операций удваивается или утраивается. Даже если каждая проверка состоит всего из пары умножений и сложения, кадр начинает дробиться на мелкие куски времени, особенно на мобильных CPU.

Physics.OverlapSphere кажется удобнее, потому что задача перекладывается на PhysX. Однако цена здесь не исчезает, а меняет форму. Запросы через физику работают по коллайдерам, требуют синхронизации с физическим миром, создают дополнительную нагрузку на broadphase и narrowphase, а затем ещё и возвращают массив или список результатов, который нужно обработать. Если запросы идут часто, в разных системах и для объектов, которым физика вообще не нужна, вы переплачиваете за инфраструктуру, которой не пользуетесь. Это особенно заметно в проектах вроде MFPS Mobile, где одновременно живут стрельба, враги, патроны, эффекты и UI, а каждый лишний миллисекундный хвост на CPU начинает бить по стабильности FPS.

Spatial hash решает задачу иначе. Он не спрашивает физический движок, кто находится рядом. Он сам знает, в каких ячейках лежат объекты, и смотрит только на область, которую может затронуть запрос. Такой подход не даёт магического ускорения для любых данных и любых радиусов, но при правильной настройке ячейки резко уменьшает количество сравнений. Особенно хорошо он работает, когда объекты распределены по сцене неравномерно, а запросы локальны.

Как работает spatial hash на уровне идеи

Пространство делится на кубы или квадраты одинакового размера. Для каждого объекта вычисляется индекс ячейки по его позиции. Затем объект добавляется в структуру данных по этому ключу. При поиске соседей вы берёте ячейку, в которой находится точка запроса, и соседние ячейки вокруг неё. Внутри них уже проверяете точное расстояние до кандидатов. За счёт этого вы отбрасываете основную массу объектов до дорогой проверки радиуса.

Если говорить на языке сложности, вставка объекта в сетку близка к O(1) в среднем, а запрос зависит от количества объектов в нескольких ячейках, а не от общего числа объектов в сцене. Чем меньше радиус и чем лучше подобран размер ячейки, тем меньше кандидатов приходится просматривать. На практике это даёт выигрыш в задачах, где запросов много, а позиции объектов меняются не каждый кадр у всех сразу.

Есть несколько вариантов реализации. Для 2D-плоскости можно использовать пару целых координат (cellX, cellY). Для 3D - тройку (cellX, cellY, cellZ). В Unity чаще всего удобно брать Vector3, но хранить индекс ячейки лучше как собственную структуру или как закодированное целое значение. Для начала достаточно простого варианта с Dictionary<CellKey, List<int>>, где в списке лежат индексы объектов в отдельном массиве позиций.

Если вам нужно много запросов по радиусу и относительно немного удалений, spatial hash обычно выгоднее, чем Physics.OverlapSphere. Если нужна точная физическая коллизия, триггеры, контакты и реакции на препятствия, физика остаётся правильным выбором.

Базовая структура данных для хэш-сетки

Ниже показан минимальный, но рабочий вариант для 3D. Он не зависит от коллайдеров и хранит только позиции и id объектов. Такой код удобно встраивать в системы, где уже есть собственные сущности: враги, ресурсы, снаряды, точки интереса.

using System.Collections.Generic;
using UnityEngine;

public struct CellKey
{
    public readonly int X;
    public readonly int Y;
    public readonly int Z;

    public CellKey(int x, int y, int z)
    {
        X = x;
        Y = y;
        Z = z;
    }

    public override bool Equals(object obj)
    {
        if (obj is not CellKey other)
            return false;

        return X == other.X && Y == other.Y && Z == other.Z;
    }

    public override int GetHashCode()
    {
        unchecked
        {
            int hash = 17;
            hash = hash * 31 + X;
            hash = hash * 31 + Y;
            hash = hash * 31 + Z;
            return hash;
        }
    }
}

public sealed class SpatialHashGrid
{
    private readonly float cellSize;
    private readonly Dictionary<CellKey, List<int>> cells = new();
    private readonly Dictionary<int, CellKey> objectToCell = new();
    private readonly Dictionary<int, Vector3> positions = new();

    public SpatialHashGrid(float cellSize)
    {
        this.cellSize = cellSize;
    }

    private CellKey GetCellKey(Vector3 position)
    {
        int x = Mathf.FloorToInt(position.x / cellSize);
        int y = Mathf.FloorToInt(position.y / cellSize);
        int z = Mathf.FloorToInt(position.z / cellSize);
        return new CellKey(x, y, z);
    }

    public void AddOrUpdate(int id, Vector3 position)
    {
        CellKey newKey = GetCellKey(position);
        positions[id] = position;

        if (objectToCell.TryGetValue(id, out CellKey oldKey))
        {
            if (!oldKey.Equals(newKey))
            {
                RemoveFromCell(id, oldKey);
                AddToCell(id, newKey);
                objectToCell[id] = newKey;
            }
        }
        else
        {
            AddToCell(id, newKey);
            objectToCell.Add(id, newKey);
        }
    }

    public void Remove(int id)
    {
        if (!objectToCell.TryGetValue(id, out CellKey key))
            return;

        RemoveFromCell(id, key);
        objectToCell.Remove(id);
        positions.Remove(id);
    }

    private void AddToCell(int id, CellKey key)
    {
        if (!cells.TryGetValue(key, out List<int> list))
        {
            list = new List<int>();
            cells.Add(key, list);
        }

        list.Add(id);
    }

    private void RemoveFromCell(int id, CellKey key)
    {
        if (!cells.TryGetValue(key, out List<int> list))
            return;

        int index = list.IndexOf(id);
        if (index >= 0)
        {
            int last = list.Count - 1;
            list[index] = list[last];
            list.RemoveAt(last);
        }

        if (list.Count == 0)
            cells.Remove(key);
    }
}

Здесь объект идентифицируется через int id. Такой подход удобен для производительности, потому что не заставляет хранить ссылки на компоненты в самой сетке. Реальные ссылки на Transform или ваши сущности можно держать в отдельном массиве или словаре на уровне системы. Это особенно полезно, если код потом пойдёт в сторону ECS-подобной архитектуры или job-friendly структур.

Впрочем, приведённый вариант ещё не идеален. Удаление через List.IndexOf линейное, а Dictionary<CellKey, List<int>> создаёт лишние аллокации при расширении списков. Для прототипа это нормально, но для частых обновлений лучше перейти к более аккуратной реализации, где список хранит индексы в массиве, а удаление делается через swap-back. Об этом чуть позже.

Как выбрать размер ячейки и не испортить результат

Размер ячейки определяет почти всё. Если ячейки слишком маленькие, один запрос по радиусу придётся проверять через много соседних ячеек. Если ячейки слишком большие, в каждой окажется слишком много объектов, и выигрыш от пространственного разбиения резко снизится. Поэтому настройка cellSize должна исходить не из красивого числа, а из типичного радиуса поиска и распределения объектов.

Практическое правило простое: если запросы по радиусу примерно одинаковы, размер ячейки часто берут равным этому радиусу или немного больше. Для задачи поиска ближайших врагов в радиусе 6 метров можно начать с ячейки 6 или 8 метров. Для спавна предметов на земле и проверки свободного места размер нередко делают по масштабу геймплейной единицы, например 1, 2 или 4 метра. Для 2D-игр, таких как Snake 2D Lite или Hypercasual - Runner Starter Kit, выбор часто вообще сводится к сетке уровня, и spatial hash можно подогнать под тайловую геометрию.

Если радиусы сильно различаются, одной сетки может не хватить. В таком случае либо берут ячейку под самый частый запрос и мирятся с более дорогими редкими запросами, либо делают несколько сеток для разных подсистем. Например, одна сетка обслуживает агро-радиус врагов, другая - систему подбора предметов, третья - видимость и триггеры для AI. Это лучше, чем пытаться одной структурой закрыть всё сразу.

Ошибка с маленькими ячейками

Когда ячейка в разы меньше радиуса поиска, запрос требует проверки многих соседних клеток. В 3D это может оказаться 27, 125 или ещё больше ячеек, если искать по кубу вокруг точки. На бумаге spatial hash всё равно может выигрывать, но уже не так заметно, потому что возрастает overhead на обход ключей и доступ к словарю. Для малых радиусов и очень плотной среды надо сравнивать варианты на реальных данных.

Ошибка с большими ячейками

Если ячейка слишком крупная, то почти все объекты в ней становятся кандидатами, и вы снова приближаетесь к полному перебору. Такой выбор часто делают из желания упростить код, но потом получают слабый результат и делают неверный вывод, что spatial hash не работает. На деле не работает только неправильный размер ячейки.

Запрос по радиусу без Physics.OverlapSphere

Сам запрос строится в два этапа. Сначала вы определяете, какие ячейки попадают в область поиска. Затем внутри каждой ячейки проверяете точную дистанцию до объекта. Это важно: spatial hash отсекает кандидатов, но не заменяет проверку расстояния. Иначе вы начнёте возвращать объекты из соседних клеток, которые фактически находятся далеко от точки.

Ниже пример метода поиска по радиусу. Он возвращает список id объектов, попавших в диапазон. Чтобы не выделять память каждый вызов, список лучше передавать извне и переиспользовать.

using System.Collections.Generic;
using UnityEngine;

public sealed class SpatialHashGrid
{
    private readonly float cellSize;
    private readonly Dictionary<CellKey, List<int>> cells = new();
    private readonly Dictionary<int, Vector3> positions = new();

    public SpatialHashGrid(float cellSize)
    {
        this.cellSize = cellSize;
    }

    private CellKey GetCellKey(Vector3 position)
    {
        return new CellKey(
            Mathf.FloorToInt(position.x / cellSize),
            Mathf.FloorToInt(position.y / cellSize),
            Mathf.FloorToInt(position.z / cellSize));
    }

    public void AddOrUpdate(int id, Vector3 position)
    {
        positions[id] = position;
        CellKey key = GetCellKey(position);

        if (!cells.TryGetValue(key, out List<int> list))
        {
            list = new List<int>();
            cells.Add(key, list);
        }

        if (!list.Contains(id))
            list.Add(id);
    }

    public void QueryRadius(Vector3 center, float radius, List<int> results)
    {
        results.Clear();

        int minX = Mathf.FloorToInt((center.x - radius) / cellSize);
        int maxX = Mathf.FloorToInt((center.x + radius) / cellSize);
        int minY = Mathf.FloorToInt((center.y - radius) / cellSize);
        int maxY = Mathf.FloorToInt((center.y + radius) / cellSize);
        int minZ = Mathf.FloorToInt((center.z - radius) / cellSize);
        int maxZ = Mathf.FloorToInt((center.z + radius) / cellSize);

        float radiusSqr = radius * radius;

        for (int x = minX; x <= maxX; x++)
        {
            for (int y = minY; y <= maxY; y++)
            {
                for (int z = minZ; z <= maxZ; z++)
                {
                    var key = new CellKey(x, y, z);
                    if (!cells.TryGetValue(key, out List<int> list))
                        continue;

                    for (int i = 0; i < list.Count; i++)
                    {
                        int id = list[i];
                        Vector3 pos = positions[id];

                        if ((pos - center).sqrMagnitude <= radiusSqr)
                            results.Add(id);
                    }
                }
            }
        }
    }
}

В этом виде код понятен и уже полезен, но есть тонкость. Если ячейка намного меньше радиуса, диапазон minX/maxX и т. д. охватывает много клеток, что увеличивает число обращений к словарю. Если же радиус небольшой, а ячейки близки по размеру к запросу, такой код работает особенно хорошо. Для 2D-игр логика та же, только убирается ось Z.

Фильтрация через sqrMagnitude вместо Vector3.Distance снижает лишние вычисления корня. Это классическая оптимизация, и она особенно важна, когда запросы идут сотни раз за кадр. Такой подход сочетается с большим количеством систем, в том числе с проектами вроде Survival Island | Template + Editor или (STP) Survival Template PRO, где одновременно работают AI, предметы, зоны интереса и поиск целей.

Оптимизация вставки и удаления без лишних аллокаций

Базовая версия через List<int> и Dictionary понятна, но для частых обновлений позиций её стоит укрепить. Основные источники лишней стоимости здесь просты: поиск элемента внутри списка, пересоздание списков, обращение к словарю по каждому объекту и лишние аллокации при масштабировании коллекций. В динамической сцене с постоянно движущимися сущностями это начинает мешать уже после нескольких сотен объектов.

Один из рабочих способов - хранить не просто id в ячейке, а индекс объекта в общем массиве, а также обратную ссылку на позицию этого объекта внутри списка ячейки. Тогда удаление становится O(1) за счёт swap-back. Для этого удобно завести структуру данных с отдельной записью о месте объекта в сетке. Ниже показан более производительный вариант.

using System.Collections.Generic;
using UnityEngine;

public sealed class SpatialHashGridFast
{
    private readonly float cellSize;
    private readonly Dictionary<CellKey, List<int>> cells = new();
    private readonly Dictionary<int, CellKey> objectCell = new();
    private readonly Dictionary<int, int> objectIndexInCell = new();
    private readonly Dictionary<int, Vector3> positions = new();

    public SpatialHashGridFast(float cellSize)
    {
        this.cellSize = cellSize;
    }

    private CellKey GetCellKey(Vector3 position)
    {
        return new CellKey(
            Mathf.FloorToInt(position.x / cellSize),
            Mathf.FloorToInt(position.y / cellSize),
            Mathf.FloorToInt(position.z / cellSize));
    }

    public void AddOrUpdate(int id, Vector3 position)
    {
        positions[id] = position;
        CellKey newKey = GetCellKey(position);

        if (objectCell.TryGetValue(id, out CellKey oldKey))
        {
            if (oldKey.Equals(newKey))
                return;

            RemoveFromCell(id, oldKey);
        }

        AddToCell(id, newKey);
        objectCell[id] = newKey;
    }

    public void Remove(int id)
    {
        if (!objectCell.TryGetValue(id, out CellKey key))
            return;

        RemoveFromCell(id, key);
        objectCell.Remove(id);
        objectIndexInCell.Remove(id);
        positions.Remove(id);
    }

    private void AddToCell(int id, CellKey key)
    {
        if (!cells.TryGetValue(key, out List<int> list))
        {
            list = new List<int>(8);
            cells.Add(key, list);
        }

        objectIndexInCell[id] = list.Count;
        list.Add(id);
    }

    private void RemoveFromCell(int id, CellKey key)
    {
        if (!cells.TryGetValue(key, out List<int> list))
            return;

        int removeIndex = objectIndexInCell[id];
        int lastIndex = list.Count - 1;
        int lastId = list[lastIndex];

        list[removeIndex] = lastId;
        objectIndexInCell[lastId] = removeIndex;
        list.RemoveAt(lastIndex);
        objectIndexInCell.Remove(id);

        if (list.Count == 0)
            cells.Remove(key);
    }

    public void QueryRadius(Vector3 center, float radius, List<int> results)
    {
        results.Clear();

        int minX = Mathf.FloorToInt((center.x - radius) / cellSize);
        int maxX = Mathf.FloorToInt((center.x + radius) / cellSize);
        int minY = Mathf.FloorToInt((center.y - radius) / cellSize);
        int maxY = Mathf.FloorToInt((center.y + radius) / cellSize);
        int minZ = Mathf.FloorToInt((center.z - radius) / cellSize);
        int maxZ = Mathf.FloorToInt((center.z + radius) / cellSize);

        float radiusSqr = radius * radius;

        for (int x = minX; x <= maxX; x++)
        {
            for (int y = minY; y <= maxY; y++)
            {
                for (int z = minZ; z <= maxZ; z++)
                {
                    if (!cells.TryGetValue(new CellKey(x, y, z), out List<int> list))
                        continue;

                    for (int i = 0; i < list.Count; i++)
                    {
                        int id = list[i];
                        Vector3 pos = positions[id];

                        if ((pos - center).sqrMagnitude <= radiusSqr)
                            results.Add(id);
                    }
                }
            }
        }
    }
}

Здесь ключевой момент в том, что удаление не ищет объект по всему списку, а сразу знает позицию элемента. Это особенно полезно, если объекты часто мигрируют между ячейками. На практике такой паттерн часто применяют в системах толпы, вражеского AI и симуляциях, где каждый агент обновляет своё положение десятки раз в секунду.

Для ещё большего контроля можно заранее резервировать ёмкость списков, если ожидается плотная заселенность ячеек. Например, new List<int>(8) или new List<int>(16) снижает число расширений. Это мелочь, но в горячем коде такие детали складываются в заметный выигрыш. Если проект использует ассеты с большим числом активных объектов, например Toon RTS Units - Demo или MultiFPS - Multiplayer FPS, на структуре соседства экономить не стоит.

Бенчмарк против полного перебора и Physics.OverlapSphere

Сравнивать алгоритмы нужно не на ощущениях, а на одинаковом наборе данных. В Unity для такого сравнения достаточно замерить время выполнения в редакторе или в Development Build через Stopwatch. Бенчмарк ниже не претендует на идеальную лабораторную точность, но хорошо показывает порядок различий между полным перебором и spatial hash.

using System.Collections.Generic;
using System.Diagnostics;
using UnityEngine;
using Debug = UnityEngine.Debug;

public class SpatialHashBenchmark : MonoBehaviour
{
    [SerializeField] private int objectCount = 5000;
    [SerializeField] private float areaSize = 200f;
    [SerializeField] private float queryRadius = 6f;
    [SerializeField] private int queryCount = 1000;

    private readonly List<Vector3> points = new();
    private readonly List<int> results = new();
    private SpatialHashGridFast grid;

    private void Start()
    {
        grid = new SpatialHashGridFast(queryRadius);
        GeneratePoints();
        BuildGrid();
        RunBenchmark();
    }

    private void GeneratePoints()
    {
        points.Clear();
        for (int i = 0; i < objectCount; i++)
        {
            Vector3 p = new Vector3(
                Random.Range(-areaSize, areaSize),
                Random.Range(-areaSize, areaSize),
                Random.Range(-areaSize, areaSize));
            points.Add(p);
        }
    }

    private void BuildGrid()
    {
        for (int i = 0; i < points.Count; i++)
            grid.AddOrUpdate(i, points[i]);
    }

    private void RunBenchmark()
    {
        Vector3 center = Vector3.zero;

        Stopwatch sw = Stopwatch.StartNew();
        int totalA = 0;
        for (int q = 0; q < queryCount; q++)
        {
            results.Clear();
            grid.QueryRadius(center, queryRadius, results);
            totalA += results.Count;
        }
        sw.Stop();
        Debug.Log($"SpatialHash: {sw.ElapsedMilliseconds} ms, total hits {totalA}");

        sw.Restart();
        int totalB = 0;
        float radiusSqr = queryRadius * queryRadius;
        for (int q = 0; q < queryCount; q++)
        {
            int hitCount = 0;
            for (int i = 0; i < points.Count; i++)
            {
                if ((points[i] - center).sqrMagnitude <= radiusSqr)
                    hitCount++;
            }
            totalB += hitCount;
        }
        sw.Stop();
        Debug.Log($"BruteForce: {sw.ElapsedMilliseconds} ms, total hits {totalB}");
    }
}

На типичной сцене с 5000 точек, радиусом 6 и 1000 запросов полный перебор легко уходит в десятки миллисекунд на кадр, если выполнять его постоянно. Spatial hash в таком случае часто сокращает время до единиц миллисекунд или меньше, если ячейка подобрана разумно и распределение объектов не слишком скучено. Точные цифры зависят от процессора, версии Unity, профиля сборки, режима IL2CPP или Mono, а также от плотности точек, но порядок разницы обычно заметен сразу.

Что касается Physics.OverlapSphere, его корректно сравнивать только в проекте с тем же количеством коллайдеров и тем же сценарием вызова. В большинстве практических случаев он окажется ощутимо дороже, чем spatial hash, если нужен просто список ближайших сущностей без физического ответа. Но если объекты уже живут в физике, а запросы редкие, переход на собственную сетку может не окупиться. Поэтому выбор стоит делать по профилю нагрузки, а не по догадке.

Бенчмарк имеет смысл прогонять в Build, а не только в Editor. В редакторе нагрузка часто искажена отладкой, сериализацией и фоновыми службами, а итоговый разрыв между алгоритмами может выглядеть либо слишком большим, либо слишком маленьким.

Что делать с движущимися объектами и частыми обновлениями

Хэш-сетка особенно хороша, когда перемещения объектов не заставляют пересобрать всю структуру с нуля. Если меняются позиции только части сущностей, обновление идёт точечно. Для каждого объекта достаточно проверить, сместился ли он в другую ячейку. Если ячейка не изменилась, можно ограничиться обновлением позиции. Если изменилась, объект удаляется из старой ячейки и добавляется в новую.

Этот принцип критичен для систем с десятками и сотнями moving actors. Враги, бегущие по сцене, персонажи в кооперативе, машины, пули, дроны, ресурсы, падающие предметы - все они могут обновляться через одну и ту же сетку. При этом не нужно перестраивать структуру на каждом кадре целиком. Пересборка всей сетки оправдана только если почти все объекты меняют клетку одновременно, например при телепортации сцены или при резкой перестройке уровня.

Если объекты очень быстро движутся и могут перескакивать через несколько ячеек за кадр, это не ломает spatial hash, но требует аккуратного обновления на каждом tick. Позиция должна обновляться по факту нового координатного значения, а не по предположению о том, что объект переместился лишь на соседнюю клетку. В противном случае сетка начнёт врать, и запросы дадут нестабильный результат.

Практический паттерн обновления

Ниже показан удобный шаблон для компонента Unity, который синхронизирует Transform с сеткой. Такой компонент можно повесить на NPC, предметы или любые сущности, которые должны участвовать в поиске соседей.

using UnityEngine;

public class SpatialHashMember : MonoBehaviour
{
    public int Id { get; private set; }
    private SpatialHashGridFast grid;
    private Vector3 lastPosition;

    public void Initialize(int id, SpatialHashGridFast spatialGrid)
    {
        Id = id;
        grid = spatialGrid;
        lastPosition = transform.position;
        grid.AddOrUpdate(Id, lastPosition);
    }

    private void Update()
    {
        if (grid == null)
            return;

        Vector3 current = transform.position;
        if ((current - lastPosition).sqrMagnitude < 0.000001f)
            return;

        lastPosition = current;
        grid.AddOrUpdate(Id, current);
    }

    private void OnDestroy()
    {
        if (grid != null)
            grid.Remove(Id);
    }
}

Здесь используется очень простой порог, чтобы не дёргать структуру на микроскопические изменения. Для некоторых проектов это полезно, особенно если позиция объекта рассчитывается через физику или плавную интерполяцию и на каждом кадре даёт почти одинаковые значения. Но если нужна максимально точная актуальность, порог можно убрать.

В проектах, где много однотипных юнитов, подобный компонент хорошо сочетается с собственным Update Manager. Тогда обновления соседства, движения и AI можно централизовать и распределить по кадрам, а не грузить каждую сущность отдельным Update().

Частые ошибки при внедрении spatial hash

Первая ошибка - считать, что spatial hash автоматически решает всё без настройки. На практике плохой размер ячейки, неудачный способ обновления и лишние аллокации могут почти обнулить эффект. Если код каждый запрос создаёт новые списки, а каждая вставка провоцирует перераспределение памяти, выгода исчезает очень быстро. По этой причине результаты нужно смотреть в профайлере, а не только в логах.

Вторая ошибка - использовать Distance вместо квадратной дистанции в горячем цикле. Это частая мелочь, которая в сумме съедает заметное время. Внутри запроса расстояние лучше считать один раз как radiusSqr, а сравнивать именно квадрат длины. При большом числе кандидатов разница становится ощутимой.

Третья ошибка - хранить в ячейках сами компоненты MonoBehaviour, а не компактный индекс или id. Для небольшого проекта это не катастрофа, но в нагруженной системе ссылки на компоненты усложняют перенос данных, мешают более дешёвой сериализации и затрудняют дальнейшую оптимизацию. Если система ближе к data-oriented подходу, лучше держать сетку отдельно от объектов сцены.

Четвёртая ошибка - смешивать в одной сетке сущности с разной природой. Например, одновременно складывать в неё и точки спавна, и врагов, и временные эффекты, и служебные объекты. Тогда запросы начинают возвращать лишние данные, а фильтрация раздувает время. Лучше либо вводить типы/маски, либо разделять сетки по подсистемам.

Когда spatial hash не подходит лучше физики

Есть задачи, где собственная сетка будет лишней. Если запросы редкие, а объектов мало, писать и поддерживать структуру ради экономии нескольких микросекунд смысла нет. Если вам нужна точная геометрия столкновения, а не просто соседство, физика полезнее. Если важны нормали, триггерные события, совместимость с коллайдерами, raycast и прочие элементы физического стека, отключаться от него ради spatial hash тоже не стоит.

Ещё один случай - когда радиус запроса велик и покрывает значительную часть карты. Тогда пространственное деление начинает терять смысл, потому что проверять приходится слишком много ячеек и слишком много объектов внутри них. Если, например, система ищет соседей почти на половине арены, выигрыш будет слабым. В такой ситуации лучше пересмотреть сам дизайн запроса, а не просто заменить API.

Для сетевых или кооперативных игр, где уже используются физические коллайдеры, иногда выгодно сочетать оба метода. Ближние и частые запросы можно перевести на spatial hash, а редкие точные проверки оставить на физику. Такой гибрид часто оказывается самым разумным. Он помогает, например, в проектах вроде Modular Multiplayer FPS Engine (Photon 2), где разные подсистемы имеют очень разную цену по CPU.

Как встроить spatial hash в реальный Unity-проект

В готовом проекте удобнее выделить сетку в отдельный сервис или менеджер. Он создаётся при старте сцены, принимает регистрацию сущностей, обновляет позиции и отвечает на запросы соседства. Такой слой не должен знать о конкретной боевой логике, AI или UI. Его задача - только хранить пространственное разбиение и выдавать кандидатов.

Если проект крупный, полезно сделать интерфейс для регистрации и удаления объектов. Тогда разные системы смогут взаимодействовать с сеткой через один контракт. Например, враг может регистрироваться при спавне, предмет - при появлении в мире, а снаряд - только на время полёта. Это упростит поддержку и снизит риск утечек ссылок. Для архитектурного оформления такого слоя удобно сочетать его с материалами вроде интерфейсы в C# для Unity.

В проектах на Unity 2022 LTS, Unity 6 и близких версиях такой код хорошо работает и в Mono, и в IL2CPP, если не перегружать его аллокациями. Для мобильных платформ особенно ценны предсказуемость и отсутствие лишних сборок мусора. Если запросы идут часто, список результатов стоит переиспользовать, а не создавать каждый раз. Если ячейки плотные, полезно мониторить GC Alloc в Profiler, чтобы случайный boxing или временный список не испортил всю картину.

Если нужен более сложный сценарий, spatial hash можно расширить. В неё добавляют маски типов, уровни приоритета, отдельные структуры для статических и динамических объектов. Статические объекты можно строить один раз при загрузке уровня, а динамические держать в отдельной сетке. Это особенно хорошо работает на больших картах, где большая часть сцены не меняется, как в окружениях из Low Poly Ultimate Pack или Low Poly Nature Environment Medieval Fantasy.

Практические выводы для внедрения

Если в проекте есть частый поиск соседей, начните с отдельной spatial hash-сетки вместо повторяющихся Physics.OverlapSphere и полного перебора. Сразу подберите размер ячейки под реальный радиус самой частой проверки, уберите лишние аллокации и сравнивайте кандидатов через квадрат расстояния. После этого прогоните бенчмарк на своём целевом устройстве и проверьте результат в Profiler и Build.

Если сетка показывает выигрыш, переведите на неё только те подсистемы, где запросов много и точная физика не нужна. Для AI, спавна, подбора предметов и локальных зон влияния это обычно даёт самый заметный эффект. Для коллизий, триггеров и физического контакта оставьте физику там, где она действительно нужна.

Комментарии

Чтобы оставить комментарий, войдите в аккаунт.

Пока нет комментариев. Будьте первым!

Смотрите также