From 2b9d1fd10596690e846a31816e61fe785872a248 Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Sun, 14 Jun 2026 22:39:36 +0300 Subject: [PATCH 1/2] Perf: kill per-frame allocations and string lookups in hot sim paths - AnimalNeedsSystem (per-frame, every animal): drop the string-keyed genome lookup and Mass.OfAnimal call; Kleiber factor is now (size/refBody)^2.25 using a precomputed AnimalSet.Species.RefBodySize. No dict probe per animal per frame. - AnimalActionSystem: fetch the Skills component ONCE per acting animal instead of twice per action (SkillFactor + GrantXp took in Skills now). - RecomputeCapacities: apply the senescence scale inside CapacityCalc.Compute instead of a second pass that allocated a List of keys every recompute (per animal per health tick, and per injury in combat). - AnimalFactory.SkillSeed: iterate genome.Alleles (IReadOnlyDictionary, no copy) instead of ToDictionary() which allocated a dict per spawn. Behaviour-preserving (same numbers). Build + --check-content clean. Co-Authored-By: Claude Opus 4.8 --- src/LittleSim/Content/AnimalSet.cs | 8 ++++++ src/LittleSim/Sim/AnimalFactory.cs | 2 +- src/LittleSim/Sim/AnimalOrganism.cs | 12 ++------- src/LittleSim/Sim/AnimalSystems.cs | 42 +++++++++++++---------------- src/LittleSim/Sim/CapacityCalc.cs | 14 +++++++--- 5 files changed, 40 insertions(+), 38 deletions(-) diff --git a/src/LittleSim/Content/AnimalSet.cs b/src/LittleSim/Content/AnimalSet.cs index ca3fbb8..50681be 100644 --- a/src/LittleSim/Content/AnimalSet.cs +++ b/src/LittleSim/Content/AnimalSet.cs @@ -41,6 +41,10 @@ public sealed class AnimalSet /// Набор генов вида: базовые значения для генерации особи. public required GenomeTemplate Template { get; init; } + + /// Эталонный размер тела вида (база GeneMaxBodySize) — предпосчитан для горячих путей + /// (масса/метаболизм Клайбера каждый кадр), чтобы не делать строковый словарный лукап в цикле. + public required float RefBodySize { get; init; } } // Встроенный набор стадий по умолчанию (если вид не задал свой): как было до выноса в данные. @@ -121,6 +125,10 @@ public sealed class AnimalSet Stages = def.Stages.Length > 0 ? def.Stages : DefaultStages, Body = content.Defs.TryGet(def.Body, out var body) ? body : null, Template = BuildTemplate(def.Genome, genes), + RefBodySize = + def.Genome.TryGetValue("GeneMaxBodySize", out var refSize) && refSize > 0f + ? refSize + : 1f, }; _index[def] = i; } diff --git a/src/LittleSim/Sim/AnimalFactory.cs b/src/LittleSim/Sim/AnimalFactory.cs index 1d15c7c..4ce20a1 100644 --- a/src/LittleSim/Sim/AnimalFactory.cs +++ b/src/LittleSim/Sim/AnimalFactory.cs @@ -90,7 +90,7 @@ public static class AnimalFactory private static int SkillSeed(Genome genome) { var acc = 0; - foreach (var (_, allele) in genome.ToDictionary()) + foreach (var (_, allele) in genome.Alleles) // IReadOnlyDictionary без копии — без аллокации { acc += (int)(allele.A * 7919f) + (int)(allele.B * 104729f); } diff --git a/src/LittleSim/Sim/AnimalOrganism.cs b/src/LittleSim/Sim/AnimalOrganism.cs index 61e7102..2a53ad3 100644 --- a/src/LittleSim/Sim/AnimalOrganism.cs +++ b/src/LittleSim/Sim/AnimalOrganism.cs @@ -216,7 +216,8 @@ public sealed class HealthState Pain = Math.Clamp(pain, 0f, 1f); - var caps = CapacityCalc.Compute(Parts, BloodLevel, Pain); + // Множитель старения применяется внутри Compute (без второго прохода/аллокации списка ключей). + var caps = CapacityCalc.Compute(Parts, BloodLevel, Pain, capacityScale); foreach (var h in _hediffs) { if (h.Def.CapMods.Count == 0) @@ -231,15 +232,6 @@ public sealed class HealthState } } - if (capacityScale < 1f) - { - var scale = Math.Clamp(capacityScale, 0f, 1f); - foreach (var capacity in new List(caps.Keys)) - { - caps[capacity] *= scale; - } - } - Capacities = caps; } diff --git a/src/LittleSim/Sim/AnimalSystems.cs b/src/LittleSim/Sim/AnimalSystems.cs index 4b03c52..441731d 100644 --- a/src/LittleSim/Sim/AnimalSystems.cs +++ b/src/LittleSim/Sim/AnimalSystems.cs @@ -155,15 +155,18 @@ public sealed class AnimalNeedsSystem( } // Обмен веществ = ген × цена устойчивости к яду (C2) × множитель Клайбера по массе тела: - // расход ∝ масса^0.75, нормирован на эталон вида → молодняк экономнее, крупные особи - // прожорливее (масса растёт с размером/возрастом). Множитель к расходу убывающих нужд. + // расход ∝ масса^0.75 = (линейный размер/эталон)^2.25 (масса ∝ размер³), нормирован на + // эталон вида → молодняк экономнее, крупные прожорливее. Эталон предпосчитан (без словаря). var sp = animals[o[i].Species]; var stageScale = sp.Stages[Math.Clamp(g[i].Stage, 0, sp.Stages.Length - 1)].Scale; - var mass = Mass.OfAnimal(sp.Def, o[i].Traits, stageScale); + var sizeRatio = MathF.Max( + 0.01f, + o[i].Traits.BodySize * stageScale / sp.RefBodySize + ); var metabolism = MathF.Max(0.1f, o[i].Traits.Metabolism) * (1f + ToleranceMetabolicCost * Math.Clamp(o[i].Traits.ToxinTolerance, 0f, 1f)) - * Mass.KleiberFactor(mass, sp.Def.BaseMassKg); + * MathF.Pow(sizeRatio, 2.25f); var lethalEmpty = false; for (var k = 0; k < needs.Count; k++) { @@ -985,6 +988,7 @@ public sealed class AnimalActionSystem : BaseSystem var eating = hh[i].State.Capacity(AnimalCapacities.Eating); var foodEff = eating * hh[i].State.Capacity(AnimalCapacities.Digestion); var self = entities.EntityAt(i); + self.TryGetComponent(out var skills); // один лукап на особь (эффекты + XP) // Ноша замедляет (задел под перенос): перегруз сверх грузоподъёмности роняет скорость. if (self.TryGetComponent(out var carrier) && carrier.CarriedKg > 0f) { @@ -1009,7 +1013,7 @@ public sealed class AnimalActionSystem : BaseSystem if (MoveTo(ref pos, brain.Target, speed * seconds)) { // Навык добычи корма: опытный форажир извлекает больше сытости; растёт от практики. - var forageF = SkillFactor(_forageSkill, self); + var forageF = SkillFactor(_forageSkill, skills); Refill(values, AnimalActions.Eat, days, foodEff * forageF); Graze( brain.TargetPlant, @@ -1025,7 +1029,7 @@ public sealed class AnimalActionSystem : BaseSystem ); // Эндозоохория: шанс проглотить семя зрелого растения (высадит позже). TryIngestSeed(brain.TargetPlant, self); - GrantXp(_forageSkill, self, days); + GrantXp(_forageSkill, skills, days); } break; @@ -1079,9 +1083,9 @@ public sealed class AnimalActionSystem : BaseSystem speed * seconds, days, foodEff, - SkillFactor(_huntSkill, self) + SkillFactor(_huntSkill, skills) ); - GrantXp(_huntSkill, self, days); + GrantXp(_huntSkill, skills, days); break; case AnimalActions.Flee: @@ -1089,9 +1093,9 @@ public sealed class AnimalActionSystem : BaseSystem MoveTo( ref pos, brain.Target, - speed * FleeSpeedMult * SkillFactor(_evadeSkill, self) * seconds + speed * FleeSpeedMult * SkillFactor(_evadeSkill, skills) * seconds ); - GrantXp(_evadeSkill, self, days); + GrantXp(_evadeSkill, skills, days); break; default: // Wander @@ -1278,14 +1282,9 @@ public sealed class AnimalActionSystem : BaseSystem } // Бонус-множитель от уровня навыка: 0.7 (новичок) … 1.3 (мастер). Навык не определён модом → 1 (без эффекта). - private float SkillFactor(int idx, Entity self) + private static float SkillFactor(int idx, in Skills s) { - if ( - idx < 0 - || !self.TryGetComponent(out var s) - || s.Levels is null - || idx >= s.Levels.Length - ) + if (idx < 0 || s.Levels is null || idx >= s.Levels.Length) { return 1f; } @@ -1294,14 +1293,9 @@ public sealed class AnimalActionSystem : BaseSystem } // Начисляет опыт навыку от практики: рост ∝ скорость обучения × страсть × (1−уровень) (убывающая отдача). - private void GrantXp(int idx, Entity self, float days) + private void GrantXp(int idx, in Skills s, float days) { - if ( - idx < 0 - || !self.TryGetComponent(out var s) - || s.Levels is null - || idx >= s.Levels.Length - ) + if (idx < 0 || s.Levels is null || idx >= s.Levels.Length) { return; } diff --git a/src/LittleSim/Sim/CapacityCalc.cs b/src/LittleSim/Sim/CapacityCalc.cs index 2e2af92..ea120d5 100644 --- a/src/LittleSim/Sim/CapacityCalc.cs +++ b/src/LittleSim/Sim/CapacityCalc.cs @@ -40,8 +40,14 @@ public static class AnimalCapacities /// public static class CapacityCalc { - /// Считает карту способностей из инстансов частей, уровня крови (0..1) и боли (0..1). - public static Dictionary Compute(PartInstance[] parts, float blood, float pain) + /// Считает карту способностей из инстансов частей, уровня крови (0..1) и боли (0..1); + /// — общий множитель (старение), применяется к итогу без лишних аллокаций. + public static Dictionary Compute( + PartInstance[] parts, + float blood, + float pain, + float capacityScale = 1f + ) { // Сырой вклад каждой способности (Σ доля·HP) и НАБОР объявленных телом способностей: вид имеет // только те, к которым его части вообще причастны (у оленя нет манипуляции). Объявленность — @@ -100,10 +106,12 @@ public static class CapacityCalc // В результат — только объявленные телом способности: известные по формуле зависимостей, прочие // (модовые) — сырым вкладом. Так у вида видны ровно его способности, а не весь каталог. + var scale = Clamp01(capacityScale); var result = new Dictionary(System.StringComparer.Ordinal); foreach (var capacity in declared) { - result[capacity] = computed.TryGetValue(capacity, out var v) ? v : Raw(capacity); + var value = computed.TryGetValue(capacity, out var v) ? v : Raw(capacity); + result[capacity] = scale < 1f ? value * scale : value; } return result; From 15cad3924041eb1c0cf5f5710cfd4e10cbd1b59a Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Sun, 14 Jun 2026 22:44:35 +0300 Subject: [PATCH 2/2] Perf: spatial grid for plant foraging (drops the dominant O(plants) scan) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plants vastly outnumber animals, and every herbivore forage decision scanned ALL plants — the dominant per-decision cost. New PlantGrid (uniform cell grid, cell lists pooled so rebuild is alloc-free) is rebuilt once per frame from plant positions; TryFindPlant/TryFindBestPlant now gather only candidates from cells overlapping the vision radius instead of iterating every plant. The entry carries position/id/toxicity/palatability so no component refetch is needed. Selection is unchanged (nearest / max-attractiveness, id tie-break), and since the result is order-invariant the behaviour is identical and deterministic. Build + check clean. Note: the animal-side neighbour scans (threat/prey/mate/herd) are still O(animals) per decision — animals are far fewer than plants, and an animal grid is a riskier change best done with a GUI run; deferred. Co-Authored-By: Claude Opus 4.8 --- src/LittleSim/Sim/AnimalSystems.cs | 87 ++++++++++++++++++------------ src/LittleSim/Sim/PlantGrid.cs | 73 +++++++++++++++++++++++++ 2 files changed, 126 insertions(+), 34 deletions(-) create mode 100644 src/LittleSim/Sim/PlantGrid.cs diff --git a/src/LittleSim/Sim/AnimalSystems.cs b/src/LittleSim/Sim/AnimalSystems.cs index 441731d..a4f73d7 100644 --- a/src/LittleSim/Sim/AnimalSystems.cs +++ b/src/LittleSim/Sim/AnimalSystems.cs @@ -264,6 +264,12 @@ 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 = []; + public AnimalDecisionSystem( EntityStore store, GameClock clock, @@ -281,6 +287,7 @@ public sealed class AnimalDecisionSystem : BaseSystem _cellSize = cellSize; _shore = shore; _rng = new Random(seed); + _plantGrid = new PlantGrid(cellSize * PlantGridCells); _animals = store.Query< AnimalNeeds, AnimalBrain, @@ -323,6 +330,7 @@ public sealed class AnimalDecisionSystem : BaseSystem protected override void OnUpdateGroup() { var delta = _clock.DeltaTime; + RebuildPlantGrid(); foreach ( var (needsChunk, brains, organisms, growths, transforms, entities) in _animals.Chunks ) @@ -720,25 +728,42 @@ public sealed class AnimalDecisionSystem : BaseSystem ? TryFindBestPlant(from, radius, toxinTolerance, out plantId, out position) : TryFindPlant(from, radius, out plantId, out position); - // Рефлекс: ближайшее растение в радиусе (ничья — меньший id, детерминизм). + // Перестраивает пространственный индекс растений из их текущих позиций (раз в кадр, линейно). + private void RebuildPlantGrid() + { + _plantGrid.Clear(); + foreach (var (transforms, _, organisms, entities) in _plants.Chunks) + { + var t = transforms.Span; + var o = organisms.Span; + for (var i = 0; i < t.Length; i++) + { + ref readonly var tr = ref o[i].Traits; + _plantGrid.Add( + entities.EntityAt(i).Id, + t[i].Position, + tr.Toxicity, + tr.Palatability + ); + } + } + } + + // Рефлекс: ближайшее растение в радиусе (ничья — меньший id, детерминизм). Кандидаты — из сетки. private bool TryFindPlant(Vector2 from, float radius, out int plantId, out Vector2 position) { var bestSq = radius * radius; plantId = -1; position = default; - foreach (var (transforms, _, _, entities) in _plants.Chunks) + _plantGrid.Collect(from, radius, _plantBuf); + foreach (var e in _plantBuf) { - var t = transforms.Span; - for (var i = 0; i < t.Length; i++) + var sq = Vector2.DistanceSquared(from, e.Pos); + if (sq < bestSq || (sq == bestSq && e.Id < plantId)) { - var sq = Vector2.DistanceSquared(from, t[i].Position); - var id = entities.EntityAt(i).Id; - if (sq < bestSq || (sq == bestSq && id < plantId)) - { - bestSq = sq; - plantId = id; - position = t[i].Position; - } + bestSq = sq; + plantId = e.Id; + position = e.Pos; } } @@ -762,31 +787,25 @@ public sealed class AnimalDecisionSystem : BaseSystem var bestScore = float.NegativeInfinity; plantId = -1; position = default; - foreach (var (transforms, _, organisms, entities) in _plants.Chunks) + _plantGrid.Collect(from, radius, _plantBuf); + foreach (var e in _plantBuf) { - var t = transforms.Span; - var o = organisms.Span; - for (var i = 0; i < t.Length; i++) + var sq = Vector2.DistanceSquared(from, e.Pos); + if (sq > radiusSq) { - var sq = Vector2.DistanceSquared(from, t[i].Position); - if (sq > radiusSq) - { - continue; - } + continue; + } - ref readonly var tr = ref o[i].Traits; - var perceivedToxin = tr.Toxicity * (1f - tol); - var score = - tr.Palatability - - ToxinAvoidWeight * perceivedToxin - - ForageDistanceWeight * (radius > 0f ? MathF.Sqrt(sq) / radius : 0f); - var id = entities.EntityAt(i).Id; - if (score > bestScore || (score == bestScore && id < plantId)) - { - bestScore = score; - plantId = id; - position = t[i].Position; - } + var perceivedToxin = e.Toxicity * (1f - tol); + var score = + e.Palatability + - ToxinAvoidWeight * perceivedToxin + - ForageDistanceWeight * (radius > 0f ? MathF.Sqrt(sq) / radius : 0f); + if (score > bestScore || (score == bestScore && e.Id < plantId)) + { + bestScore = score; + plantId = e.Id; + position = e.Pos; } } diff --git a/src/LittleSim/Sim/PlantGrid.cs b/src/LittleSim/Sim/PlantGrid.cs new file mode 100644 index 0000000..6eca745 --- /dev/null +++ b/src/LittleSim/Sim/PlantGrid.cs @@ -0,0 +1,73 @@ +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)); +}