Глава 7.7: Оптимизация производительности
Главная | << Предыдущая: Событийно-ориентированная архитектура | Оптимизация производительности
Введение
DayZ работает при 10--60 серверных FPS в зависимости от количества игроков, нагрузки сущностей и сложности модов. Каждый цикл скриптов, который занимает слишком много времени, съедает бюджет кадра. Один плохо написанный OnUpdate, который сканирует все транспортные средства на карте или пересоздаёт список UI с нуля, может заметно снизить производительность сервера. Профессиональные моды зарабатывают свою репутацию тем, что работают быстро --- не тем, что имеют больше возможностей, а тем, что реализуют те же возможности с меньшими затратами.
Эта глава охватывает проверенные временем паттерны оптимизации, используемые в COT, VPP, Expansion и Dabs Framework. Это не преждевременные оптимизации --- это стандартные инженерные практики, которые каждый моддер DayZ должен знать с самого начала.
Содержание
- Ленивая загрузка и пакетная обработка
- Пулинг виджетов
- Дебаунсинг поиска
- Ограничение частоты обновлений
- Кэширование
- Паттерн реестра транспортных средств
- Выбор алгоритма сортировки
- Чего следует избегать
- Профилирование
- Чек-лист
Ленивая загрузка и пакетная обработка
Самая эффективная оптимизация в моддинге DayZ --- это не выполнять работу до тех пор, пока она не понадобится и распределять работу по нескольким кадрам, когда она должна быть выполнена.
Ленивая загрузка
Никогда не вычисляйте и не загружайте заранее данные, которые пользователю могут не понадобиться:
class ItemDatabase
{
protected ref map<string, ref ItemData> m_Cache;
protected bool m_Loaded;
// ПЛОХО: Загрузка всего при запуске
void OnInit()
{
LoadAllItems(); // 5000 предметов, задержка 200мс при запуске
}
// ХОРОШО: Загрузка при первом доступе
ItemData GetItem(string className)
{
if (!m_Loaded)
{
LoadAllItems();
m_Loaded = true;
}
ItemData data;
m_Cache.Find(className, data);
return data;
}
};Пакетная обработка (N элементов за кадр)
Когда вам необходимо обработать большую коллекцию, обрабатывайте фиксированную порцию за кадр вместо всей коллекции сразу:
class LootCleanup : MyServerModule
{
protected ref array<Object> m_DirtyItems;
protected int m_ProcessIndex;
static const int BATCH_SIZE = 50; // Обрабатываем 50 элементов за кадр
override void OnUpdate(float dt)
{
if (!m_DirtyItems || m_DirtyItems.Count() == 0) return;
int processed = 0;
while (m_ProcessIndex < m_DirtyItems.Count() && processed < BATCH_SIZE)
{
Object item = m_DirtyItems[m_ProcessIndex];
if (item)
{
ProcessItem(item);
}
m_ProcessIndex++;
processed++;
}
// Сброс после завершения
if (m_ProcessIndex >= m_DirtyItems.Count())
{
m_DirtyItems.Clear();
m_ProcessIndex = 0;
}
}
void ProcessItem(Object item) { ... }
};Почему 50?
Размер пакета зависит от того, насколько дорого обрабатывать каждый элемент. Для лёгких операций (проверки null, чтение позиций) 100--200 за кадр вполне допустимо. Для тяжёлых операций (спавн сущностей, запросы поиска пути, файловый ввод/вывод) предел может составлять 5--10 за кадр. Начните с 50 и корректируйте на основе наблюдаемого влияния на время кадра.
Пулинг виджетов
Создание и уничтожение виджетов UI затратно. Движок должен выделить память, построить дерево виджетов, применить стили и рассчитать компоновку. Если у вас прокручиваемый список с 500 записями, создание 500 виджетов, их уничтожение и создание 500 новых каждый раз при обновлении списка --- это гарантированное проседание кадров.
Проблема
// ПЛОХО: Уничтожаем и пересоздаём при каждом обновлении
void RefreshPlayerList(array<string> players)
{
// Уничтожаем все существующие виджеты
Widget child = m_ListPanel.GetChildren();
while (child)
{
Widget next = child.GetSibling();
child.Unlink(); // Уничтожить
child = next;
}
// Создаём новые виджеты для каждого игрока
for (int i = 0; i < players.Count(); i++)
{
Widget row = GetGame().GetWorkspace().CreateWidgets("MyMod/layouts/PlayerRow.layout", m_ListPanel);
TextWidget nameText = TextWidget.Cast(row.FindAnyWidget("NameText"));
nameText.SetText(players[i]);
}
}Паттерн пула
Предварительно создайте пул строк виджетов. При обновлении переиспользуйте существующие строки. Показывайте строки с данными; скрывайте строки без данных.
class WidgetPool
{
protected ref array<Widget> m_Pool;
protected Widget m_Parent;
protected string m_LayoutPath;
protected int m_ActiveCount;
void WidgetPool(Widget parent, string layoutPath, int initialSize)
{
m_Parent = parent;
m_LayoutPath = layoutPath;
m_Pool = new array<Widget>();
m_ActiveCount = 0;
// Предварительное создание пула
for (int i = 0; i < initialSize; i++)
{
Widget w = GetGame().GetWorkspace().CreateWidgets(m_LayoutPath, m_Parent);
w.Show(false);
m_Pool.Insert(w);
}
}
// Получить виджет из пула, создавая новые при необходимости
Widget Acquire()
{
if (m_ActiveCount < m_Pool.Count())
{
Widget w = m_Pool[m_ActiveCount];
w.Show(true);
m_ActiveCount++;
return w;
}
// Пул исчерпан — расширяем его
Widget newWidget = GetGame().GetWorkspace().CreateWidgets(m_LayoutPath, m_Parent);
m_Pool.Insert(newWidget);
m_ActiveCount++;
return newWidget;
}
// Скрыть все активные виджеты (но не уничтожать их)
void ReleaseAll()
{
for (int i = 0; i < m_ActiveCount; i++)
{
m_Pool[i].Show(false);
}
m_ActiveCount = 0;
}
// Уничтожить весь пул (вызывать при очистке)
void Destroy()
{
for (int i = 0; i < m_Pool.Count(); i++)
{
if (m_Pool[i]) m_Pool[i].Unlink();
}
m_Pool.Clear();
m_ActiveCount = 0;
}
};Использование
void RefreshPlayerList(array<string> players)
{
m_WidgetPool.ReleaseAll(); // Скрываем все — без уничтожения
for (int i = 0; i < players.Count(); i++)
{
Widget row = m_WidgetPool.Acquire(); // Переиспользуем или создаём
TextWidget nameText = TextWidget.Cast(row.FindAnyWidget("NameText"));
nameText.SetText(players[i]);
}
}Первый вызов RefreshPlayerList создаёт виджеты. Каждый последующий вызов переиспользует их. Никакого уничтожения, никакого пересоздания, никакого проседания кадров.
Дебаунсинг поиска
Когда пользователь вводит текст в поле поиска, событие OnChange срабатывает при каждом нажатии клавиши. Перестраивать отфильтрованный список при каждом нажатии --- расточительно, ведь пользователь ещё продолжает печатать. Вместо этого задерживайте поиск до тех пор, пока пользователь не сделает паузу.
Паттерн дебаунсинга
class SearchableList
{
protected const float DEBOUNCE_DELAY = 0.15; // 150мс
protected float m_SearchTimer;
protected bool m_SearchPending;
protected string m_PendingQuery;
// Вызывается при каждом нажатии клавиши
void OnSearchTextChanged(string text)
{
m_PendingQuery = text;
m_SearchPending = true;
m_SearchTimer = 0; // Сбрасываем таймер при каждом нажатии
}
// Вызывается каждый кадр из OnUpdate
void Tick(float dt)
{
if (!m_SearchPending) return;
m_SearchTimer += dt;
if (m_SearchTimer >= DEBOUNCE_DELAY)
{
m_SearchPending = false;
ExecuteSearch(m_PendingQuery);
}
}
void ExecuteSearch(string query)
{
// Теперь выполняем фактическую фильтрацию
// Это выполняется один раз после того, как пользователь перестаёт печатать, а не при каждом нажатии
}
};Почему 150мс?
150мс --- хорошее значение по умолчанию. Оно достаточно большое, чтобы большинство нажатий при непрерывном наборе группировались в один поиск, но достаточно маленькое, чтобы интерфейс ощущался отзывчивым. Корректируйте, если ваш поиск особенно затратный (больше задержка) или пользователи ожидают мгновенный отклик (меньше задержка).
Ограничение частоты обновлений
Не всё нужно выполнять каждый кадр. Многие системы могут обновляться с меньшей частотой без какого-либо заметного влияния.
Ограничение на основе таймера
class EntityScanner : MyServerModule
{
protected const float SCAN_INTERVAL = 5.0; // Каждые 5 секунд
protected float m_ScanTimer;
override void OnUpdate(float dt)
{
m_ScanTimer += dt;
if (m_ScanTimer < SCAN_INTERVAL) return;
m_ScanTimer = 0;
// Затратное сканирование выполняется каждые 5 секунд, а не каждый кадр
ScanEntities();
}
};Ограничение по счётчику кадров
Для операций, которые должны выполняться каждые N кадров:
class PositionSync
{
protected int m_FrameCounter;
protected const int SYNC_EVERY_N_FRAMES = 10; // Каждый 10-й кадр
void OnUpdate(float dt)
{
m_FrameCounter++;
if (m_FrameCounter % SYNC_EVERY_N_FRAMES != 0) return;
SyncPositions();
}
};Распределённая обработка
Когда нескольким системам нужны периодические обновления, смещайте их таймеры, чтобы они не срабатывали все в одном кадре:
// ПЛОХО: Все три срабатывают при t=5.0, t=10.0, t=15.0 — пик нагрузки на кадр
m_LootTimer = 5.0;
m_VehicleTimer = 5.0;
m_WeatherTimer = 5.0;
// ХОРОШО: Со смещением — работа распределяется
m_LootTimer = 5.0;
m_VehicleTimer = 5.0 + 1.6; // Срабатывает ~1.6с после лута
m_WeatherTimer = 5.0 + 3.3; // Срабатывает ~3.3с после лутаИли запускайте таймеры с разными начальными смещениями:
m_LootTimer = 0;
m_VehicleTimer = 1.6;
m_WeatherTimer = 3.3;Кэширование
Повторные поиски одних и тех же данных --- распространённая причина потери производительности. Кэшируйте результаты.
Кэш сканирования CfgVehicles
Сканирование CfgVehicles (глобальная конфигурационная база всех классов предметов/транспортных средств) затратно. Оно включает перебор тысяч записей конфигурации. Никогда не делайте это более одного раза:
class WeaponRegistry
{
private static ref array<string> s_AllWeapons;
// Строим один раз, используем постоянно
static array<string> GetAllWeapons()
{
if (s_AllWeapons) return s_AllWeapons;
s_AllWeapons = new array<string>();
int cfgCount = GetGame().ConfigGetChildrenCount("CfgVehicles");
string className;
for (int i = 0; i < cfgCount; i++)
{
GetGame().ConfigGetChildName("CfgVehicles", i, className);
if (GetGame().IsKindOf(className, "Weapon_Base"))
{
s_AllWeapons.Insert(className);
}
}
return s_AllWeapons;
}
static void Cleanup()
{
s_AllWeapons = null;
}
};Кэш строковых операций
Если вы многократно вычисляете одно и то же строковое преобразование (например, приведение к нижнему регистру для поиска без учёта регистра), кэшируйте результат:
class ItemEntry
{
string DisplayName;
string SearchName; // Предвычисленная строка в нижнем регистре для поиска
void ItemEntry(string displayName)
{
DisplayName = displayName;
SearchName = displayName;
SearchName.ToLower(); // Вычисляем один раз
}
};Кэш позиций
Если вы часто проверяете "находится ли игрок рядом с X?", кэшируйте позицию игрока и обновляйте её периодически, вместо вызова GetPosition() при каждой проверке:
class ProximityChecker
{
protected vector m_CachedPosition;
protected float m_PositionAge;
vector GetCachedPosition(EntityAI entity, float dt)
{
m_PositionAge += dt;
if (m_PositionAge > 1.0) // Обновляем каждую секунду
{
m_CachedPosition = entity.GetPosition();
m_PositionAge = 0;
}
return m_CachedPosition;
}
};Паттерн реестра транспортных средств
Частая задача --- отслеживать все транспортные средства (или все сущности определённого типа) на карте. Наивный подход --- вызвать GetGame().GetObjectsAtPosition3D() с огромным радиусом. Это катастрофически затратно.
Плохо: Сканирование мира
// УЖАСНО: Сканирует каждый объект в радиусе 50 км каждый кадр
void FindAllVehicles()
{
array<Object> objects = new array<Object>();
GetGame().GetObjectsAtPosition3D(Vector(7500, 0, 7500), 50000, objects);
foreach (Object obj : objects)
{
CarScript car = CarScript.Cast(obj);
if (car) { ... }
}
}Хорошо: Реестр на основе регистрации
Отслеживайте сущности по мере их создания и уничтожения:
class VehicleRegistry
{
private static ref array<CarScript> s_Vehicles = new array<CarScript>();
static void Register(CarScript vehicle)
{
if (vehicle && s_Vehicles.Find(vehicle) == -1)
{
s_Vehicles.Insert(vehicle);
}
}
static void Unregister(CarScript vehicle)
{
int idx = s_Vehicles.Find(vehicle);
if (idx >= 0) s_Vehicles.Remove(idx);
}
static array<CarScript> GetAll()
{
return s_Vehicles;
}
static void Cleanup()
{
s_Vehicles.Clear();
}
};
// Встраиваемся в создание/уничтожение транспортных средств:
modded class CarScript
{
override void EEInit()
{
super.EEInit();
if (GetGame().IsServer())
{
VehicleRegistry.Register(this);
}
}
override void EEDelete(EntityAI parent)
{
if (GetGame().IsServer())
{
VehicleRegistry.Unregister(this);
}
super.EEDelete(parent);
}
};Теперь VehicleRegistry.GetAll() мгновенно возвращает все транспортные средства --- сканирование мира не требуется.
Паттерн связного списка Expansion
Expansion развивает эту идею дальше с двусвязным списком на самом классе сущности, избегая затрат на операции с массивами:
// Паттерн Expansion (концептуальный):
class ExpansionVehicle
{
ExpansionVehicle m_Next;
ExpansionVehicle m_Prev;
static ExpansionVehicle s_Head;
void Register()
{
m_Next = s_Head;
if (s_Head) s_Head.m_Prev = this;
s_Head = this;
}
void Unregister()
{
if (m_Prev) m_Prev.m_Next = m_Next;
if (m_Next) m_Next.m_Prev = m_Prev;
if (s_Head == this) s_Head = m_Next;
m_Next = null;
m_Prev = null;
}
};Это даёт O(1) вставку и удаление с нулевым выделением памяти на операцию. Итерация --- простой обход указателей от s_Head.
Выбор алгоритма сортировки
Массивы Enforce Script имеют встроенный метод .Sort(), но он работает только для базовых типов и использует сравнение по умолчанию. Для пользовательских порядков сортировки вам нужна функция сравнения.
Встроенная сортировка
array<int> numbers = {5, 2, 8, 1, 9, 3};
numbers.Sort(); // {1, 2, 3, 5, 8, 9}
array<string> names = {"Charlie", "Alice", "Bob"};
names.Sort(); // {"Alice", "Bob", "Charlie"} — лексикографическаяПользовательская сортировка с функцией сравнения
Для сортировки массивов объектов по конкретному полю реализуйте свою сортировку. Сортировка вставками хороша для небольших массивов (менее ~100 элементов); для больших массивов быстрая сортировка работает лучше.
// Простая сортировка вставками — хороша для небольших массивов
void SortPlayersByScore(array<ref PlayerData> players)
{
for (int i = 1; i < players.Count(); i++)
{
ref PlayerData key = players[i];
int j = i - 1;
while (j >= 0 && players[j].Score < key.Score)
{
players[j + 1] = players[j];
j--;
}
players[j + 1] = key;
}
}Избегайте сортировки каждый кадр
Если отсортированный список отображается в UI, сортируйте его один раз при изменении данных, а не каждый кадр:
// ПЛОХО: Сортировка каждый кадр
void OnUpdate(float dt)
{
SortPlayersByScore(m_Players);
RefreshUI();
}
// ХОРОШО: Сортировка только при изменении данных
void OnPlayerScoreChanged()
{
SortPlayersByScore(m_Players);
RefreshUI();
}Чего следует избегать
1. GetObjectsAtPosition3D с огромным радиусом
Эта функция сканирует каждый физический объект в мире в заданном радиусе. При 50000 метрах (вся карта) она перебирает каждое дерево, камень, здание, предмет, зомби и игрока. Один вызов может занять 50мс+.
// НИКОГДА НЕ ДЕЛАЙТЕ ТАК
GetGame().GetObjectsAtPosition3D(Vector(7500, 0, 7500), 50000, results);Используйте реестр на основе регистрации (см. Паттерн реестра транспортных средств).
2. Полная пересборка списка при каждом нажатии клавиши
// ПЛОХО: Пересборка 5000 строк виджетов при каждом нажатии
void OnSearchChanged(string text)
{
DestroyAllRows();
foreach (ItemData item : m_AllItems)
{
if (item.Name.Contains(text))
{
CreateWidgetRow(item);
}
}
}Используйте дебаунсинг поиска и пулинг виджетов вместо этого.
3. Аллокации строк каждый кадр
Конкатенация строк создаёт новые строковые объекты. В покадровой функции это генерирует мусор каждый кадр:
// ПЛОХО: Две новые аллокации строк за кадр на сущность
void OnUpdate(float dt)
{
for (int i = 0; i < m_Entities.Count(); i++)
{
string label = "Entity_" + i.ToString(); // Новая строка каждый кадр
string info = label + " at " + m_Entities[i].GetPosition().ToString(); // Ещё одна новая строка
}
}Если вам нужны форматированные строки для логирования или UI, делайте это при изменении состояния, а не каждый кадр.
4. Избыточные проверки FileExist в циклах
// ПЛОХО: Проверка FileExist для одного и того же пути 500 раз
for (int i = 0; i < m_Players.Count(); i++)
{
if (FileExist("$profile:MyMod/Config.json")) // Один и тот же файл, 500 проверок
{
// ...
}
}
// ХОРОШО: Проверяем один раз
bool configExists = FileExist("$profile:MyMod/Config.json");
for (int i = 0; i < m_Players.Count(); i++)
{
if (configExists)
{
// ...
}
}5. Многократный вызов GetGame()
GetGame() --- это вызов глобальной функции. В плотных циклах кэшируйте результат:
// Допустимо для разового использования
if (GetGame().IsServer()) { ... }
// В плотном цикле кэшируйте:
CGame game = GetGame();
for (int i = 0; i < 1000; i++)
{
if (game.IsServer()) { ... }
}6. Спавн сущностей в плотном цикле
Спавн сущностей затратен (настройка физики, сетевая репликация и т.д.). Никогда не спавните десятки сущностей в одном кадре:
// ПЛОХО: 100 спавнов сущностей за один кадр — массивный пик нагрузки
for (int i = 0; i < 100; i++)
{
GetGame().CreateObjectEx("Zombie", randomPos, ECE_PLACE_ON_SURFACE);
}Используйте пакетную обработку: спавните по 5 за кадр на протяжении 20 кадров.
Профилирование
Мониторинг серверного FPS
Самая базовая метрика --- серверный FPS. Если ваш мод роняет серверный FPS, что-то не так:
// В вашем OnUpdate измеряйте затраченное время:
void OnUpdate(float dt)
{
float startTime = GetGame().GetTickTime();
// ... ваша логика ...
float elapsed = GetGame().GetTickTime() - startTime;
if (elapsed > 0.005) // Больше 5мс
{
MyLog.Warning("Perf", "OnUpdate took " + elapsed.ToString() + "s");
}
}Индикаторы в логе скриптов
Следите за логом скриптов сервера DayZ на предмет этих предупреждений о производительности:
SCRIPT (W): Exceeded X ms--- выполнение скрипта превысило временной бюджет движка- Длительные паузы во временных метках лога --- что-то заблокировало основной поток
Эмпирическое тестирование
Единственный надёжный способ узнать, имеет ли оптимизация значение --- это замерить до и после:
- Добавьте замеры времени вокруг подозрительного кода
- Запустите воспроизводимый тест (например, 50 игроков, 1000 сущностей)
- Сравните время кадров
- Если разница менее 1мс за кадр, она, вероятно, не имеет значения
Чек-лист
Перед выпуском производительно-критичного кода проверьте:
- [ ] Нет вызовов
GetObjectsAtPosition3Dс радиусом > 100м в покадровом коде - [ ] Все затратные сканирования (CfgVehicles, поиск сущностей) кэшированы
- [ ] Списки UI используют пулинг виджетов, а не уничтожение/пересоздание
- [ ] Поисковые поля ввода используют дебаунсинг (150мс+)
- [ ] Операции OnUpdate ограничены таймером или размером пакета
- [ ] Большие коллекции обрабатываются пакетами (50 элементов/кадр по умолчанию)
- [ ] Спавн сущностей пакетирован по кадрам, а не выполняется в плотном цикле
- [ ] Конкатенация строк не выполняется покадрово в плотных циклах
- [ ] Операции сортировки выполняются при изменении данных, а не каждый кадр
- [ ] Несколько периодических систем имеют смещённые таймеры
- [ ] Отслеживание сущностей использует регистрацию, а не сканирование мира
Совместимость и влияние
- Мульти-мод: Затраты на производительность кумулятивны.
OnUpdateкаждого мода выполняется каждый кадр. Пять модов, каждый из которых занимает 2мс --- это 10мс за кадр только от скриптов. Координируйтесь с другими авторами модов для смещения таймеров и избежания дублирующего сканирования мира. - Порядок загрузки: Порядок загрузки напрямую не влияет на производительность. Однако если несколько модов используют
modded classдля одной и той же сущности (например,CarScript.EEInit), каждое переопределение добавляет к стоимости цепочки вызовов. Минимизируйте modded-переопределения. - Listen-сервер: Listen-серверы выполняют и клиентские, и серверные скрипты в одном процессе. Пулинг виджетов, обновления UI и затраты на рендеринг суммируются с серверными тиками. Бюджеты производительности на listen-серверах жёстче, чем на выделенных серверах.
- Производительность: Бюджет кадра сервера DayZ при 60 FPS составляет ~16мс. При 20 FPS (типично на загруженных серверах) --- ~50мс. Один мод должен стремиться оставаться в пределах 2мс за кадр. Профилируйте с помощью
GetGame().GetTickTime()для проверки. - Миграция: Паттерны производительности не зависят от движка и переживают обновления DayZ. Конкретные стоимости API (например,
GetObjectsAtPosition3D) могут меняться между версиями движка, поэтому перепрофилируйте после крупных обновлений DayZ.
Типичные ошибки
| Ошибка | Влияние | Исправление |
|---|---|---|
| Преждевременная оптимизация (микрооптимизация кода, который выполняется один раз при запуске) | Потрачено время разработки; нет измеримого улучшения; код труднее читать | Сначала профилируйте. Оптимизируйте только код, который выполняется каждый кадр или обрабатывает большие коллекции. Стоимость запуска платится один раз. |
Использование GetObjectsAtPosition3D с радиусом на всю карту в OnUpdate | Задержка 50--200мс на вызов, сканирование каждого физического объекта на карте; серверный FPS падает до единиц | Используйте реестр на основе регистрации (регистрация в EEInit, отмена регистрации в EEDelete). Никогда не сканируйте мир покадрово. |
| Пересборка деревьев виджетов UI при каждом изменении данных | Пики нагрузки от создания/уничтожения виджетов; видимые подёргивания для игрока | Используйте пулинг виджетов: скрывайте/показывайте существующие виджеты вместо уничтожения и пересоздания |
| Сортировка больших массивов каждый кадр | O(n log n) каждый кадр для данных, которые редко меняются; ненужная трата CPU | Сортируйте один раз при изменении данных (флаг изменений), кэшируйте отсортированный результат, пересортировывайте только при мутации |
Выполнение затратного файлового ввода/вывода (JsonSaveFile) в каждом тике OnUpdate | Запись на диск блокирует основной поток; 5--20мс за сохранение в зависимости от размера файла | Используйте таймеры автосохранения (300с по умолчанию) с флагом изменений. Записывайте только когда данные фактически изменились. |
Теория и практика
| В учебнике написано | Реальность DayZ |
|---|---|
| Используйте асинхронную обработку для затратных операций | Enforce Script однопоточный без асинхронных примитивов; распределяйте работу по кадрам используя обработку на основе индексов |
| Пулинг объектов --- это преждевременная оптимизация | Создание виджетов в Enfusion действительно затратно; пулинг --- стандартная практика в каждом крупном моде (COT, VPP, Expansion) |
| Профилируйте перед оптимизацией | Верно, но некоторые паттерны (сканирование мира, аллокация строк каждый кадр, пересборка при каждом нажатии) всегда ошибочны в DayZ. Избегайте их с самого начала. |
Главная | << Предыдущая: Событийно-ориентированная архитектура | Оптимизация производительности
