From b61c6acff3e05369c60e47df759502a0d191215f Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Sun, 14 Jun 2026 22:55:27 +0300 Subject: [PATCH] Perf: spatial grid for animal neighbour queries (drops O(animals^2) AI scans) Generalized the plant grid into SpatialGrid (PlantGrid.cs -> SpatialGrid.cs) and added an animal grid. AnimalDecisionSystem rebuilds both once per frame; the animal grid entry precomputes the fields the AI needs (species, body size, eats-meat, sex, adult) so queries do not refetch components. Threat / prey / mate / herd scans now gather only neighbours from cells overlapping the vision radius instead of iterating every animal per decision. IsThreatTo/IsPreyFor take precomputed values; dead MateNeedIndex removed. Mate stays species-agnostic (unchanged behaviour). Added id tie-breaks to threat/mate selection so results are order-invariant -> fully deterministic regardless of grid iteration order. Build + check clean. Co-Authored-By: Claude Opus 4.8 --- src/LittleSim/Sim/AnimalSystems.cs | 204 ++++++++++++++--------------- src/LittleSim/Sim/PlantGrid.cs | 73 ----------- src/LittleSim/Sim/SpatialGrid.cs | 92 +++++++++++++ 3 files changed, 191 insertions(+), 178 deletions(-) delete mode 100644 src/LittleSim/Sim/PlantGrid.cs create mode 100644 src/LittleSim/Sim/SpatialGrid.cs diff --git a/src/LittleSim/Sim/AnimalSystems.cs b/src/LittleSim/Sim/AnimalSystems.cs index a4f73d7..0e9735d 100644 --- a/src/LittleSim/Sim/AnimalSystems.cs +++ b/src/LittleSim/Sim/AnimalSystems.cs @@ -264,11 +264,14 @@ public sealed class AnimalDecisionSystem : BaseSystem private readonly ArchetypeQuery _plants; private readonly ArchetypeQuery _corpses; - // Пространственный индекс растений (перестраивается раз в кадр) + переиспользуемый буфер запроса — - // поиск корма берёт только ближние растения вместо прохода по всем (O(растений) на каждое решение). - private const int PlantGridCells = 10; // размер ячейки сетки в клетках мира (~радиус восприятия) - private readonly PlantGrid _plantGrid; - private readonly List _plantBuf = []; + // Пространственные индексы (перестраиваются раз в кадр) + переиспользуемые буферы запросов: поиск + // корма/угрозы/добычи/партнёра/стада берёт только ближние сущности вместо прохода по всем (было + // O(растений)+O(животных) на каждое решение). В ячейке — предпосчитанные признаки (без дорефетча). + private const int GridCells = 10; // размер ячейки сетки в клетках мира (~радиус восприятия) + private readonly SpatialGrid _plantGrid; + private readonly List _plantBuf = []; + private readonly SpatialGrid _animalGrid; + private readonly List _animalBuf = []; public AnimalDecisionSystem( EntityStore store, @@ -287,7 +290,8 @@ public sealed class AnimalDecisionSystem : BaseSystem _cellSize = cellSize; _shore = shore; _rng = new Random(seed); - _plantGrid = new PlantGrid(cellSize * PlantGridCells); + _plantGrid = new SpatialGrid(cellSize * GridCells); + _animalGrid = new SpatialGrid(cellSize * GridCells); _animals = store.Query< AnimalNeeds, AnimalBrain, @@ -330,7 +334,7 @@ public sealed class AnimalDecisionSystem : BaseSystem protected override void OnUpdateGroup() { var delta = _clock.DeltaTime; - RebuildPlantGrid(); + RebuildGrids(); foreach ( var (needsChunk, brains, organisms, growths, transforms, entities) in _animals.Chunks ) @@ -521,22 +525,18 @@ public sealed class AnimalDecisionSystem : BaseSystem var sum = Vector2.Zero; count = 0; var radiusSq = radius * radius; - foreach (var (_, _, organisms, _, transforms, entities) in _animals.Chunks) + _animalGrid.Collect(from, radius, _animalBuf); + foreach (var e in _animalBuf) { - var oo = organisms.Span; - var tt = transforms.Span; - for (var i = 0; i < tt.Length; i++) + if (e.Species != species || e.Id == selfId) { - if (oo[i].Species != species || entities.EntityAt(i).Id == selfId) - { - continue; - } + continue; + } - if (Vector2.DistanceSquared(from, tt[i].Position) <= radiusSq) - { - sum += tt[i].Position; - count++; - } + if (Vector2.DistanceSquared(from, e.Pos) <= radiusSq) + { + sum += e.Pos; + count++; } } @@ -555,52 +555,46 @@ public sealed class AnimalDecisionSystem : BaseSystem { var bestSq = radius * radius; threatPos = default; - var found = false; + var threatId = -1; var (_, selfEatsMeat) = AnimalFactory.Diet(selfTraits, _animalSet[selfSpecies].Def); var selfBody = selfTraits.BodySize; - foreach (var (_, _, organisms, _, transforms, _) in _animals.Chunks) + _animalGrid.Collect(from, radius, _animalBuf); + foreach (var e in _animalBuf) { - var oo = organisms.Span; - var tt = transforms.Span; - for (var i = 0; i < tt.Length; i++) + if ( + e.Species == selfSpecies + || !IsThreatTo(selfEatsMeat, selfBody, e.EatsMeat, e.BodySize) + ) { - if ( - oo[i].Species == selfSpecies - || !IsThreatTo(selfEatsMeat, selfBody, oo[i].Species, oo[i].Traits) - ) - { - continue; - } + continue; + } - var sq = Vector2.DistanceSquared(from, tt[i].Position); - if (sq < bestSq) - { - bestSq = sq; - threatPos = tt[i].Position; - found = true; - } + var sq = Vector2.DistanceSquared(from, e.Pos); + if (sq < bestSq || (sq == bestSq && e.Id < threatId)) + { + bestSq = sq; + threatPos = e.Pos; + threatId = e.Id; } } - return found; + return threatId >= 0; } // Является ли вид-кандидат угрозой особи: он ест мясо и не мельче её. Чистая жертва (не мясоед) // бежит от любого хищника не мельче себя; сам хищник — лишь от заметно более крупного мясоеда. - private bool IsThreatTo( + private static bool IsThreatTo( bool selfEatsMeat, float selfBody, - int otherSpecies, - in AnimalPhenotype otherTraits + bool otherEatsMeat, + float otherBody ) { - var (_, otherEatsMeat) = AnimalFactory.Diet(otherTraits, _animalSet[otherSpecies].Def); if (!otherEatsMeat) { return false; } - var otherBody = otherTraits.BodySize; return selfEatsMeat ? otherBody > selfBody * 1.1f : otherBody >= selfBody * 0.9f; } @@ -647,35 +641,25 @@ public sealed class AnimalDecisionSystem : BaseSystem } } - // 2) Живая добыча. + // 2) Живая добыча (из сетки животных). var bestPreySq = radius * radius; var preyId = -1; var preyPos = default(Vector2); var selfBody = selfTraits.BodySize; - foreach (var (_, _, organisms, _, transforms, entities) in _animals.Chunks) + _animalGrid.Collect(from, radius, _animalBuf); + foreach (var e in _animalBuf) { - var oo = organisms.Span; - var tt = transforms.Span; - for (var i = 0; i < tt.Length; i++) + if (e.Species == selfSpecies || e.Id == selfId || !IsPreyFor(selfBody, e.BodySize)) { - var id = entities.EntityAt(i).Id; - if (oo[i].Species == selfSpecies || id == selfId) - { - continue; - } + continue; + } - if (!IsPreyFor(selfBody, oo[i].Traits)) - { - continue; - } - - var sq = Vector2.DistanceSquared(from, tt[i].Position); - if (sq < bestPreySq || (sq == bestPreySq && id < preyId)) - { - bestPreySq = sq; - preyId = id; - preyPos = tt[i].Position; - } + var sq = Vector2.DistanceSquared(from, e.Pos); + if (sq < bestPreySq || (sq == bestPreySq && e.Id < preyId)) + { + bestPreySq = sq; + preyId = e.Id; + preyPos = e.Pos; } } @@ -701,8 +685,8 @@ public sealed class AnimalDecisionSystem : BaseSystem // Подходит ли особь в добычу охотнику данного размера: важно лишь, что она не крупнее охотника // (на превосходящих не нападаем). Вид/мясоедство добычи не важны — хищник ест и травоядных, и прочих. - private static bool IsPreyFor(float hunterBody, in AnimalPhenotype otherTraits) => - otherTraits.BodySize <= hunterBody * 1.25f; + private static bool IsPreyFor(float hunterBody, float otherBody) => + otherBody <= hunterBody * 1.25f; // Ближайшее растение в радиусе (квадрат расстояния); ничьи — по меньшему id (детерминизм). // Порог интеллекта, с которого травоядное РАЗБИРАЕТ корм (избегает ядовитого/невкусного). Ниже — @@ -728,8 +712,9 @@ public sealed class AnimalDecisionSystem : BaseSystem ? TryFindBestPlant(from, radius, toxinTolerance, out plantId, out position) : TryFindPlant(from, radius, out plantId, out position); - // Перестраивает пространственный индекс растений из их текущих позиций (раз в кадр, линейно). - private void RebuildPlantGrid() + // Перестраивает пространственные индексы растений и животных из текущих позиций (раз в кадр, линейно). + // Для животных СРАЗУ считает признаки, нужные ИИ (мясоед/взрослость), чтобы запросы их не пересчитывали. + private void RebuildGrids() { _plantGrid.Clear(); foreach (var (transforms, _, organisms, entities) in _plants.Chunks) @@ -740,10 +725,36 @@ public sealed class AnimalDecisionSystem : BaseSystem { ref readonly var tr = ref o[i].Traits; _plantGrid.Add( - entities.EntityAt(i).Id, - t[i].Position, - tr.Toxicity, - tr.Palatability + new ForageEntry( + entities.EntityAt(i).Id, + t[i].Position, + tr.Toxicity, + tr.Palatability + ) + ); + } + } + + _animalGrid.Clear(); + foreach (var (_, _, organisms, growths, transforms, entities) in _animals.Chunks) + { + var o = organisms.Span; + var g = growths.Span; + var t = transforms.Span; + for (var i = 0; i < t.Length; i++) + { + var sp = _animalSet[o[i].Species]; + var (_, eatsMeat) = AnimalFactory.Diet(o[i].Traits, sp.Def); + _animalGrid.Add( + new NeighborEntry( + entities.EntityAt(i).Id, + t[i].Position, + o[i].Species, + o[i].Traits.BodySize, + eatsMeat, + o[i].IsMale, + AnimalFactory.IsAdult(sp, g[i].Stage) + ) ); } } @@ -847,44 +858,27 @@ public sealed class AnimalDecisionSystem : BaseSystem var bestSq = radius * radius; mateId = -1; position = default; - var mateNeed = -1; - foreach (var (needsChunk, _, organisms, growths, transforms, entities) in _animals.Chunks) + _animalGrid.Collect(from, radius, _animalBuf); + foreach (var e in _animalBuf) { - var nn = needsChunk.Span; - var oo = organisms.Span; - var gg = growths.Span; - var tt = transforms.Span; - for (var i = 0; i < tt.Length; i++) + if (e.IsMale == selfMale || !e.IsAdult) { - if ( - oo[i].IsMale == selfMale - || !AnimalFactory.IsAdult(_animalSet[oo[i].Species], gg[i].Stage) - ) - { - continue; // тот же пол / не взрослый (self отсеивается по полу) - } + continue; // тот же пол / не взрослый (self отсеивается по полу) + } - if (mateNeed < 0) - { - mateNeed = MateNeedIndex(nn[i].Values); // влечение — самый высокий drive-кандидат - } - - var sq = Vector2.DistanceSquared(from, tt[i].Position); - if (sq > 0.01f && sq < bestSq) - { - bestSq = sq; - mateId = entities.EntityAt(i).Id; - position = tt[i].Position; - } + // Влечение партнёра не проверяется (учёт придёт с половым отбором); вид не фильтруется — + // межвидовые пары возможны, как и раньше. self отсекается совпадением пола и sq≈0. + var sq = Vector2.DistanceSquared(from, e.Pos); + if (sq > 0.01f && (sq < bestSq || (sq == bestSq && e.Id < mateId))) + { + bestSq = sq; + mateId = e.Id; + position = e.Pos; } } return mateId >= 0; } - - // Влечение партнёра отсекается в исполнении (контакт), а для выбора достаточно пола/взрослости — - // упрощённый поиск (полный учёт влечения партнёра придёт с половым отбором). Возвращает 0 (заглушка). - private static int MateNeedIndex(float[] _) => 0; } /// diff --git a/src/LittleSim/Sim/PlantGrid.cs b/src/LittleSim/Sim/PlantGrid.cs deleted file mode 100644 index 6eca745..0000000 --- a/src/LittleSim/Sim/PlantGrid.cs +++ /dev/null @@ -1,73 +0,0 @@ -using System.Collections.Generic; -using Microsoft.Xna.Framework; - -namespace LittleSim.Sim; - -/// -/// Пространственный индекс растений (равномерная сетка ячеек) для радиус-запросов поиска корма: вместо -/// прохода по ВСЕМ растениям на каждое решение травоядного (O(растений) на особь) — только растения в -/// ближних ячейках. Перестраивается раз в кадр из позиций растений (O(растений) линейно). В ячейке лежит -/// то, что нужно поиску — позиция, id, токсичность и вкусность — без дорефетча компонентов. Списки ячеек -/// переиспользуются (пул), чтобы перестройка не аллоцировала. Порядок обхода детерминирован (вложенные -/// циклы по ячейкам + порядок вставки), а выбор всё равно ломает ничьи по меньшему id — детерминизм цел. -/// -public sealed class PlantGrid -{ - /// Запись растения в ячейке: id, позиция и признаки, нужные поиску корма. - public readonly record struct Entry(int Id, Vector2 Pos, float Toxicity, float Palatability); - - private readonly float _cell; - private readonly Dictionary<(int, int), List> _cells = new(); - private readonly Stack> _pool = new(); - - public PlantGrid(float cellSize) => _cell = cellSize > 0f ? cellSize : 1f; - - /// Очищает сетку (списки ячеек возвращаются в пул для переиспользования). - public void Clear() - { - foreach (var list in _cells.Values) - { - list.Clear(); - _pool.Push(list); - } - - _cells.Clear(); - } - - /// Добавляет растение в его ячейку. - public void Add(int id, Vector2 pos, float toxicity, float palatability) - { - var key = CellOf(pos); - if (!_cells.TryGetValue(key, out var list)) - { - list = _pool.Count > 0 ? _pool.Pop() : new List(); - _cells[key] = list; - } - - list.Add(new Entry(id, pos, toxicity, palatability)); - } - - /// Собирает в растения из ячеек, перекрывающих радиус (точную - /// дистанцию проверяет вызывающий). Буфер переиспользуется — без аллокаций на запрос. - public void Collect(Vector2 center, float radius, List results) - { - results.Clear(); - var minX = (int)MathF.Floor((center.X - radius) / _cell); - var maxX = (int)MathF.Floor((center.X + radius) / _cell); - var minY = (int)MathF.Floor((center.Y - radius) / _cell); - var maxY = (int)MathF.Floor((center.Y + radius) / _cell); - for (var cx = minX; cx <= maxX; cx++) - { - for (var cy = minY; cy <= maxY; cy++) - { - if (_cells.TryGetValue((cx, cy), out var list)) - { - results.AddRange(list); - } - } - } - } - - private (int, int) CellOf(Vector2 p) => - ((int)MathF.Floor(p.X / _cell), (int)MathF.Floor(p.Y / _cell)); -} diff --git a/src/LittleSim/Sim/SpatialGrid.cs b/src/LittleSim/Sim/SpatialGrid.cs new file mode 100644 index 0000000..856265a --- /dev/null +++ b/src/LittleSim/Sim/SpatialGrid.cs @@ -0,0 +1,92 @@ +using System.Collections.Generic; +using Microsoft.Xna.Framework; + +namespace LittleSim.Sim; + +/// Запись пространственного индекса: позиция (для раскладки по ячейкам) и id (для tie-break/исключения себя). +public interface ISpatialEntry +{ + Vector2 Pos { get; } + int Id { get; } +} + +/// Растение для поиска корма: позиция/id + признаки, нужные выбору (токсичность, вкусность). +public readonly record struct ForageEntry(int Id, Vector2 Pos, float Toxicity, float Palatability) + : ISpatialEntry; + +/// Сосед-животное для ИИ: позиция/id + предпосчитанные признаки (вид, размер, мясоед, пол, взрослость), +/// чтобы запросы угрозы/добычи/партнёра/стада не дорефетчили компоненты. +public readonly record struct NeighborEntry( + int Id, + Vector2 Pos, + int Species, + float BodySize, + bool EatsMeat, + bool IsMale, + bool IsAdult +) : ISpatialEntry; + +/// +/// Пространственный индекс (равномерная сетка ячеек) для радиус-запросов: вместо прохода по ВСЕМ +/// сущностям на каждый запрос — только из ближних ячеек. Перестраивается раз в кадр (O(n) линейно); +/// списки ячеек берутся из пула, поэтому перестройка не аллоцирует. Обход детерминирован (вложенные +/// циклы по ячейкам + порядок вставки); выбор всё равно ломает ничьи по меньшему id — детерминизм цел. +/// +public sealed class SpatialGrid + where T : struct, ISpatialEntry +{ + private readonly float _cell; + private readonly Dictionary<(int, int), List> _cells = new(); + private readonly Stack> _pool = new(); + + public SpatialGrid(float cellSize) => _cell = cellSize > 0f ? cellSize : 1f; + + /// Очищает сетку (списки ячеек возвращаются в пул). + public void Clear() + { + foreach (var list in _cells.Values) + { + list.Clear(); + _pool.Push(list); + } + + _cells.Clear(); + } + + /// Добавляет элемент в его ячейку. + public void Add(T item) + { + var key = CellOf(item.Pos); + if (!_cells.TryGetValue(key, out var list)) + { + list = _pool.Count > 0 ? _pool.Pop() : new List(); + _cells[key] = list; + } + + list.Add(item); + } + + /// Собирает в элементы из ячеек, перекрывающих радиус (точную + /// дистанцию проверяет вызывающий). Буфер переиспользуется — без аллокаций на запрос. + public void Collect(Vector2 center, float radius, List results) + { + results.Clear(); + var minX = (int)MathF.Floor((center.X - radius) / _cell); + var maxX = (int)MathF.Floor((center.X + radius) / _cell); + var minY = (int)MathF.Floor((center.Y - radius) / _cell); + var maxY = (int)MathF.Floor((center.Y + radius) / _cell); + for (var cx = minX; cx <= maxX; cx++) + { + for (var cy = minY; cy <= maxY; cy++) + { + if (_cells.TryGetValue((cx, cy), out var list)) + { + results.AddRange(list); + } + } + } + } + + private (int, int) CellOf(Vector2 p) => + ((int)MathF.Floor(p.X / _cell), (int)MathF.Floor(p.Y / _cell)); +}