Merge branch 'perf-hotpaths' into main

Hot-path performance: removed per-frame allocations and string genome lookups
(Kleiber via precomputed RefBodySize, single Skills fetch, senescence scale inside
CapacityCalc, alloc-free SkillSeed), and a plant spatial grid that replaces the
dominant per-decision scan of all plants with a local cell query.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Leonid Pershin
2026-06-14 22:46:00 +03:00
co-authored by Claude Opus 4.8
6 changed files with 166 additions and 72 deletions
+8
View File
@@ -41,6 +41,10 @@ public sealed class AnimalSet
/// <summary>Набор генов вида: базовые значения для генерации особи.</summary> /// <summary>Набор генов вида: базовые значения для генерации особи.</summary>
public required GenomeTemplate Template { get; init; } public required GenomeTemplate Template { get; init; }
/// <summary>Эталонный размер тела вида (база GeneMaxBodySize) — предпосчитан для горячих путей
/// (масса/метаболизм Клайбера каждый кадр), чтобы не делать строковый словарный лукап в цикле.</summary>
public required float RefBodySize { get; init; }
} }
// Встроенный набор стадий по умолчанию (если вид не задал свой): как было до выноса в данные. // Встроенный набор стадий по умолчанию (если вид не задал свой): как было до выноса в данные.
@@ -121,6 +125,10 @@ public sealed class AnimalSet
Stages = def.Stages.Length > 0 ? def.Stages : DefaultStages, Stages = def.Stages.Length > 0 ? def.Stages : DefaultStages,
Body = content.Defs.TryGet<BodyDef>(def.Body, out var body) ? body : null, Body = content.Defs.TryGet<BodyDef>(def.Body, out var body) ? body : null,
Template = BuildTemplate(def.Genome, genes), Template = BuildTemplate(def.Genome, genes),
RefBodySize =
def.Genome.TryGetValue("GeneMaxBodySize", out var refSize) && refSize > 0f
? refSize
: 1f,
}; };
_index[def] = i; _index[def] = i;
} }
+1 -1
View File
@@ -90,7 +90,7 @@ public static class AnimalFactory
private static int SkillSeed(Genome genome) private static int SkillSeed(Genome genome)
{ {
var acc = 0; 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); acc += (int)(allele.A * 7919f) + (int)(allele.B * 104729f);
} }
+2 -10
View File
@@ -216,7 +216,8 @@ public sealed class HealthState
Pain = Math.Clamp(pain, 0f, 1f); 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) foreach (var h in _hediffs)
{ {
if (h.Def.CapMods.Count == 0) 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<string>(caps.Keys))
{
caps[capacity] *= scale;
}
}
Capacities = caps; Capacities = caps;
} }
+71 -58
View File
@@ -155,15 +155,18 @@ public sealed class AnimalNeedsSystem(
} }
// Обмен веществ = ген × цена устойчивости к яду (C2) × множитель Клайбера по массе тела: // Обмен веществ = ген × цена устойчивости к яду (C2) × множитель Клайбера по массе тела:
// расход ∝ масса^0.75, нормирован на эталон вида → молодняк экономнее, крупные особи // расход ∝ масса^0.75 = (линейный размер/эталон)^2.25 (масса ∝ размер³), нормирован на
// прожорливее (масса растёт с размером/возрастом). Множитель к расходу убывающих нужд. // эталон вида → молодняк экономнее, крупные прожорливее. Эталон предпосчитан (без словаря).
var sp = animals[o[i].Species]; var sp = animals[o[i].Species];
var stageScale = sp.Stages[Math.Clamp(g[i].Stage, 0, sp.Stages.Length - 1)].Scale; 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 = var metabolism =
MathF.Max(0.1f, o[i].Traits.Metabolism) MathF.Max(0.1f, o[i].Traits.Metabolism)
* (1f + ToleranceMetabolicCost * Math.Clamp(o[i].Traits.ToxinTolerance, 0f, 1f)) * (1f + ToleranceMetabolicCost * Math.Clamp(o[i].Traits.ToxinTolerance, 0f, 1f))
* Mass.KleiberFactor(mass, sp.Def.BaseMassKg); * MathF.Pow(sizeRatio, 2.25f);
var lethalEmpty = false; var lethalEmpty = false;
for (var k = 0; k < needs.Count; k++) for (var k = 0; k < needs.Count; k++)
{ {
@@ -261,6 +264,12 @@ public sealed class AnimalDecisionSystem : BaseSystem
private readonly ArchetypeQuery<Transform2D, PlantGrowth, PlantOrganism> _plants; private readonly ArchetypeQuery<Transform2D, PlantGrowth, PlantOrganism> _plants;
private readonly ArchetypeQuery<Transform2D, Corpse> _corpses; private readonly ArchetypeQuery<Transform2D, Corpse> _corpses;
// Пространственный индекс растений (перестраивается раз в кадр) + переиспользуемый буфер запроса —
// поиск корма берёт только ближние растения вместо прохода по всем (O(растений) на каждое решение).
private const int PlantGridCells = 10; // размер ячейки сетки в клетках мира (~радиус восприятия)
private readonly PlantGrid _plantGrid;
private readonly List<PlantGrid.Entry> _plantBuf = [];
public AnimalDecisionSystem( public AnimalDecisionSystem(
EntityStore store, EntityStore store,
GameClock clock, GameClock clock,
@@ -278,6 +287,7 @@ public sealed class AnimalDecisionSystem : BaseSystem
_cellSize = cellSize; _cellSize = cellSize;
_shore = shore; _shore = shore;
_rng = new Random(seed); _rng = new Random(seed);
_plantGrid = new PlantGrid(cellSize * PlantGridCells);
_animals = store.Query< _animals = store.Query<
AnimalNeeds, AnimalNeeds,
AnimalBrain, AnimalBrain,
@@ -320,6 +330,7 @@ public sealed class AnimalDecisionSystem : BaseSystem
protected override void OnUpdateGroup() protected override void OnUpdateGroup()
{ {
var delta = _clock.DeltaTime; var delta = _clock.DeltaTime;
RebuildPlantGrid();
foreach ( foreach (
var (needsChunk, brains, organisms, growths, transforms, entities) in _animals.Chunks var (needsChunk, brains, organisms, growths, transforms, entities) in _animals.Chunks
) )
@@ -717,25 +728,42 @@ public sealed class AnimalDecisionSystem : BaseSystem
? TryFindBestPlant(from, radius, toxinTolerance, out plantId, out position) ? TryFindBestPlant(from, radius, toxinTolerance, out plantId, out position)
: TryFindPlant(from, radius, 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) private bool TryFindPlant(Vector2 from, float radius, out int plantId, out Vector2 position)
{ {
var bestSq = radius * radius; var bestSq = radius * radius;
plantId = -1; plantId = -1;
position = default; position = default;
foreach (var (transforms, _, _, entities) in _plants.Chunks) _plantGrid.Collect(from, radius, _plantBuf);
foreach (var e in _plantBuf)
{ {
var t = transforms.Span; var sq = Vector2.DistanceSquared(from, e.Pos);
for (var i = 0; i < t.Length; i++) if (sq < bestSq || (sq == bestSq && e.Id < plantId))
{ {
var sq = Vector2.DistanceSquared(from, t[i].Position); bestSq = sq;
var id = entities.EntityAt(i).Id; plantId = e.Id;
if (sq < bestSq || (sq == bestSq && id < plantId)) position = e.Pos;
{
bestSq = sq;
plantId = id;
position = t[i].Position;
}
} }
} }
@@ -759,31 +787,25 @@ public sealed class AnimalDecisionSystem : BaseSystem
var bestScore = float.NegativeInfinity; var bestScore = float.NegativeInfinity;
plantId = -1; plantId = -1;
position = default; 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 sq = Vector2.DistanceSquared(from, e.Pos);
var o = organisms.Span; if (sq > radiusSq)
for (var i = 0; i < t.Length; i++)
{ {
var sq = Vector2.DistanceSquared(from, t[i].Position); continue;
if (sq > radiusSq) }
{
continue;
}
ref readonly var tr = ref o[i].Traits; var perceivedToxin = e.Toxicity * (1f - tol);
var perceivedToxin = tr.Toxicity * (1f - tol); var score =
var score = e.Palatability
tr.Palatability - ToxinAvoidWeight * perceivedToxin
- ToxinAvoidWeight * perceivedToxin - ForageDistanceWeight * (radius > 0f ? MathF.Sqrt(sq) / radius : 0f);
- ForageDistanceWeight * (radius > 0f ? MathF.Sqrt(sq) / radius : 0f); if (score > bestScore || (score == bestScore && e.Id < plantId))
var id = entities.EntityAt(i).Id; {
if (score > bestScore || (score == bestScore && id < plantId)) bestScore = score;
{ plantId = e.Id;
bestScore = score; position = e.Pos;
plantId = id;
position = t[i].Position;
}
} }
} }
@@ -985,6 +1007,7 @@ public sealed class AnimalActionSystem : BaseSystem
var eating = hh[i].State.Capacity(AnimalCapacities.Eating); var eating = hh[i].State.Capacity(AnimalCapacities.Eating);
var foodEff = eating * hh[i].State.Capacity(AnimalCapacities.Digestion); var foodEff = eating * hh[i].State.Capacity(AnimalCapacities.Digestion);
var self = entities.EntityAt(i); var self = entities.EntityAt(i);
self.TryGetComponent<Skills>(out var skills); // один лукап на особь (эффекты + XP)
// Ноша замедляет (задел под перенос): перегруз сверх грузоподъёмности роняет скорость. // Ноша замедляет (задел под перенос): перегруз сверх грузоподъёмности роняет скорость.
if (self.TryGetComponent<Carrier>(out var carrier) && carrier.CarriedKg > 0f) if (self.TryGetComponent<Carrier>(out var carrier) && carrier.CarriedKg > 0f)
{ {
@@ -1009,7 +1032,7 @@ public sealed class AnimalActionSystem : BaseSystem
if (MoveTo(ref pos, brain.Target, speed * seconds)) 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); Refill(values, AnimalActions.Eat, days, foodEff * forageF);
Graze( Graze(
brain.TargetPlant, brain.TargetPlant,
@@ -1025,7 +1048,7 @@ public sealed class AnimalActionSystem : BaseSystem
); );
// Эндозоохория: шанс проглотить семя зрелого растения (высадит позже). // Эндозоохория: шанс проглотить семя зрелого растения (высадит позже).
TryIngestSeed(brain.TargetPlant, self); TryIngestSeed(brain.TargetPlant, self);
GrantXp(_forageSkill, self, days); GrantXp(_forageSkill, skills, days);
} }
break; break;
@@ -1079,9 +1102,9 @@ public sealed class AnimalActionSystem : BaseSystem
speed * seconds, speed * seconds,
days, days,
foodEff, foodEff,
SkillFactor(_huntSkill, self) SkillFactor(_huntSkill, skills)
); );
GrantXp(_huntSkill, self, days); GrantXp(_huntSkill, skills, days);
break; break;
case AnimalActions.Flee: case AnimalActions.Flee:
@@ -1089,9 +1112,9 @@ public sealed class AnimalActionSystem : BaseSystem
MoveTo( MoveTo(
ref pos, ref pos,
brain.Target, brain.Target,
speed * FleeSpeedMult * SkillFactor(_evadeSkill, self) * seconds speed * FleeSpeedMult * SkillFactor(_evadeSkill, skills) * seconds
); );
GrantXp(_evadeSkill, self, days); GrantXp(_evadeSkill, skills, days);
break; break;
default: // Wander default: // Wander
@@ -1278,14 +1301,9 @@ public sealed class AnimalActionSystem : BaseSystem
} }
// Бонус-множитель от уровня навыка: 0.7 (новичок) … 1.3 (мастер). Навык не определён модом → 1 (без эффекта). // Бонус-множитель от уровня навыка: 0.7 (новичок) … 1.3 (мастер). Навык не определён модом → 1 (без эффекта).
private float SkillFactor(int idx, Entity self) private static float SkillFactor(int idx, in Skills s)
{ {
if ( if (idx < 0 || s.Levels is null || idx >= s.Levels.Length)
idx < 0
|| !self.TryGetComponent<Skills>(out var s)
|| s.Levels is null
|| idx >= s.Levels.Length
)
{ {
return 1f; return 1f;
} }
@@ -1294,14 +1312,9 @@ public sealed class AnimalActionSystem : BaseSystem
} }
// Начисляет опыт навыку от практики: рост ∝ скорость обучения × страсть × (1−уровень) (убывающая отдача). // Начисляет опыт навыку от практики: рост ∝ скорость обучения × страсть × (1−уровень) (убывающая отдача).
private void GrantXp(int idx, Entity self, float days) private void GrantXp(int idx, in Skills s, float days)
{ {
if ( if (idx < 0 || s.Levels is null || idx >= s.Levels.Length)
idx < 0
|| !self.TryGetComponent<Skills>(out var s)
|| s.Levels is null
|| idx >= s.Levels.Length
)
{ {
return; return;
} }
+11 -3
View File
@@ -40,8 +40,14 @@ public static class AnimalCapacities
/// </summary> /// </summary>
public static class CapacityCalc public static class CapacityCalc
{ {
/// <summary>Считает карту способностей из инстансов частей, уровня крови (0..1) и боли (0..1).</summary> /// <summary>Считает карту способностей из инстансов частей, уровня крови (0..1) и боли (0..1);
public static Dictionary<string, float> Compute(PartInstance[] parts, float blood, float pain) /// <paramref name="capacityScale"/> — общий множитель (старение), применяется к итогу без лишних аллокаций.</summary>
public static Dictionary<string, float> Compute(
PartInstance[] parts,
float blood,
float pain,
float capacityScale = 1f
)
{ {
// Сырой вклад каждой способности (Σ доля·HP) и НАБОР объявленных телом способностей: вид имеет // Сырой вклад каждой способности (Σ доля·HP) и НАБОР объявленных телом способностей: вид имеет
// только те, к которым его части вообще причастны (у оленя нет манипуляции). Объявленность — // только те, к которым его части вообще причастны (у оленя нет манипуляции). Объявленность —
@@ -100,10 +106,12 @@ public static class CapacityCalc
// В результат — только объявленные телом способности: известные по формуле зависимостей, прочие // В результат — только объявленные телом способности: известные по формуле зависимостей, прочие
// (модовые) — сырым вкладом. Так у вида видны ровно его способности, а не весь каталог. // (модовые) — сырым вкладом. Так у вида видны ровно его способности, а не весь каталог.
var scale = Clamp01(capacityScale);
var result = new Dictionary<string, float>(System.StringComparer.Ordinal); var result = new Dictionary<string, float>(System.StringComparer.Ordinal);
foreach (var capacity in declared) 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; return result;
+73
View File
@@ -0,0 +1,73 @@
using System.Collections.Generic;
using Microsoft.Xna.Framework;
namespace LittleSim.Sim;
/// <summary>
/// Пространственный индекс растений (равномерная сетка ячеек) для радиус-запросов поиска корма: вместо
/// прохода по ВСЕМ растениям на каждое решение травоядного (O(растений) на особь) — только растения в
/// ближних ячейках. Перестраивается раз в кадр из позиций растений (O(растений) линейно). В ячейке лежит
/// то, что нужно поиску — позиция, id, токсичность и вкусность — без дорефетча компонентов. Списки ячеек
/// переиспользуются (пул), чтобы перестройка не аллоцировала. Порядок обхода детерминирован (вложенные
/// циклы по ячейкам + порядок вставки), а выбор всё равно ломает ничьи по меньшему id — детерминизм цел.
/// </summary>
public sealed class PlantGrid
{
/// <summary>Запись растения в ячейке: id, позиция и признаки, нужные поиску корма.</summary>
public readonly record struct Entry(int Id, Vector2 Pos, float Toxicity, float Palatability);
private readonly float _cell;
private readonly Dictionary<(int, int), List<Entry>> _cells = new();
private readonly Stack<List<Entry>> _pool = new();
public PlantGrid(float cellSize) => _cell = cellSize > 0f ? cellSize : 1f;
/// <summary>Очищает сетку (списки ячеек возвращаются в пул для переиспользования).</summary>
public void Clear()
{
foreach (var list in _cells.Values)
{
list.Clear();
_pool.Push(list);
}
_cells.Clear();
}
/// <summary>Добавляет растение в его ячейку.</summary>
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<Entry>();
_cells[key] = list;
}
list.Add(new Entry(id, pos, toxicity, palatability));
}
/// <summary>Собирает в <paramref name="results"/> растения из ячеек, перекрывающих радиус (точную
/// дистанцию проверяет вызывающий). Буфер переиспользуется — без аллокаций на запрос.</summary>
public void Collect(Vector2 center, float radius, List<Entry> 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));
}