Хочу поделиться своим опытом разработки крупных игровых проектов на C++, где производительность и стабильность — это не просто приятные бонусы, а абсолютно естественные требования к разработке. За годы работы над движками и играми я понял, что подход к управлению памятью очень сильно влияет на весь проект. В отличие от многих приложений - игры, особенно большие, часто работают часами без прерываний и должны поддерживать стабильный фреймрейт и отзывчивость. Когда проседание fps или фриз происходит на глазах у сотен тысяч игроков, вам уже никто не поможет — ущерб уже нанесен, а в steam полетели отзывы о кривизне рук разработчиков.

Недавно моя команда закончила работу над довольно старым и известным проектом, который портировали больше двух лет на плойку. Движок старый, большой и мощный, но работа с памятью была ориентирована на ПК времен конца 2000-х, и что меня поразило, так это насколько сильно большая часть кодовой базы зависела от динамической памяти во время выполнения. На ограниченном железе (далеко не у всех есть PS5 pro) и в условиях жёстких требований к сертификации на консолях такие решения быстро превращаются в проблему.

В разработке для консолей (про мобильные устройства я молчу, потому что игра не влезает по памяти даже в восемь гигов) с ограниченными ресурсами, архитектура с частыми аллокациями не просто неэффективна — она становится реальной угрозой для стабильности проекта. Каждое выделение памяти в куче влечёт за собой накладные расходы: это дополнительные !миллисекунды! (в целом на кадре) задержки, риск большой фрагментации памяти, и непредсказуемое поведение в долгой игровой сессии. После двух часов игры постоянные операции с кучей буквально «сжигают» половину бюджета кадра.


Про сотни тысяч игроков я не приукрасил, ежедневное число игроков проекта моей команды частенько переваливает за 500к в стиме.

Динамическая память — это проблема в разработке игр
В играх и игровых движках, особенно на консолях и мобильных устройствах, управление памятью должно быть максимально предсказуемым. Это значит:

  • никакой неожиданной задержки из-за фрагментации кучи;

  • никакого риска падения fps из-за нехватки памяти в разгаре боя или на важной сцене;

  • никакого постепенного ухудшения производительности во время игры;

  • никаких ошибок выделения памяти, которые могут сорвать катку или вызвать вылет у игроков.

В отличие от десктопных приложений, где пользователь может «перезапустить» программу и продолжить что-то делать - игра должна стабильно работать на ограниченном железе с фиксированным объёмом памяти. Если память заканчивается, а она физически заканчивается — игра крашится. В условиях консоли или мобильного устройства это не теоретическая угроза, а практическая реальность, которая напрямую влияет на опыт тысяч игроков.

Консоль

Общий объем

Доступно для игры

Особенности архитектуры

PlayStation 5

16 Gb GDDR6

12.5-13 Gb

Единая архитектура

Xbox Series X

16 Gb GDDR6

~11.5 Gb

10 Gb высокоскоростной
+ 6 Gb стандартной

Xbox Series S

10 Gb GDDR6

~7 Gb

8 Gb высокоскоростной
+ 2 Gb стандартной

Скрытые издержки динамической памяти
Мои замеры показывают, что время выделения памяти в куче деградирует при длительной (порядка трех часов) игре в 2–5 раз на xbox и 2-3 раза на playstation5, напрямую влияя на производительность игры. На мобильниках такие длинные сессии редкость, но там фрагментация со временем может «съедать» до 30% доступной памяти в длинных (более получаса) игровых сессиях, что для платформ с ограниченными ресурсами это означает не только падение FPS, но и фактические вылеты по ООМ.

Это конечно мелочь, но разные реализации malloc добавляют 24–64 байт накладных расходов на каждое выделение для служебной информации. И если в игре, где за кадр происходят тысячи мелких аллокаций, я не ошибся - тысячи за кадр (например, при создании объектов или эффектов), этот оверхед сам по себе занимает какую-то часть памяти.

Аллокатор

Приблизный объём служебных данных на одно выделение

Комментарии

glibc malloc

16-24 байт

Хранит размер блока + флаги + указатели/связи в списках свободных блоков

jemalloc

“отдельно от блоков”, но накладные расходы всё равно есть — несколько байт за блок, и дополнительная структура

накладные расходы зависят от размера выделения и класса.

TCMalloc

32+ байт / класс — зависит от „size-class“ + кеша потоков + страниц

дополнительные расходы на данные о кешах потоков, управления “size-classes”, накладные расходы выше для мелких аллокаций.

Windows/Xbox

48+(debug) / 30+ байт (релиз)

Зависит от версии OS, режима (отладочный / релизный) и архитектуры

PlayStation

минимально 12 байт (размер + урезанный указатель на следующий блок + флаги)
80+ байт для дебажной сборки

Нет точной цифры, зависит от версии SDK.

А насыпьте мне кода без кучи...

Я, как и многие мои знакомые из мира игростроя, считаю, что современные возможности языка стали слишком «тяжелые» или лишними для разработки игр, но весь это вкусный сахар, тем не менее позволяет писать физически меньше кода. Можно успешно использовать лямбды, RAII, статический полиморфизм и даже еще не полностью изученные возможности C++23 для создания игр, не отказываться от современных инструментов, а применять их разумно. И, конечно, надо понимать ограничения наших систем и использовать только те возможности языка, которые не нарушают требований к производительности и предсказуемости.

В какой-то момент команда пришла к пониманию, что нужны аналоги привычных STL-контейнеров, но с фиксированным размером. Например, gtl::vector<T, N> у нас работает точно так же, как std::vector<T>, но может содержать максимум N элементов. Это означает, что вся память для элементов выделяется в момент создания объекта, а не динамически при добавлении элементов. Но лень, старые привычки и реактивность мозга не дают возможности писать сразу без ошибок, а ведь такой подход сулит множество преимуществ для разработки. Во-первых, размер контейнера известен на этапе компиляции, что позволяет статически анализировать потребление памяти. Во-вторых, операции добавления и удаления элементов выполняются за предсказуемое время, поскольку не требуют обращений к системе управления памятью. Это важно для понимания куда уходит время на кадре, а еще можно рисовать красивые презентации начальству, как мы тут боремся за перф.

Стандартный std::function в C++ использует динамическое выделение памяти для хранения больших объектов, что вообще неприемлемо для игр, когда каждый второй обработчик начинает использовать лямбду, обернутую в функтор. Есть несколько библиотек, которые решают эту проблему, например библитека FastDelegate (Don Clugston), написанная лет двацать назад (ссылка), но не утратившая своей актуальности или реализация функторов от ETL (ссылка)etl::function<Signature, StorageSize>, где используется буфер для хранения функционального объекта, в самом функторе, и еще как минимум пара хороших библиотек на гитхабе. Это позволяет использовать все преимущества функционального программирования - лямбда-выражения, функторы, указатели на функции — без риска неконтролируемого выделения памяти. Т.е. мы сами определяем максимальный размер функционального объекта, и если он превышает заданный лимит, компилятор просто выдаст ошибку. Теперь частенько мой код выглядит вот так:

// <<<< std::vector<int>
gtl::vector<int, 64> _unit_options;

// <<<< std::function<void()>
gtl::function<void(), 32> _unit_death_cb;

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

... и добавьте немного CRTP

В традиционном ООП на плюсах мы часто используем виртуальные функции для достижения полиморфизма. Когда у нас есть базовый класс с виртуальными методами и несколько наследников. Это обычный подход, мы перекладываем часть работы на компилятор, который создает специальную таблицу виртуальных функций (vtable). При вызове метода программа сначала обращается к этой таблице, чтобы определить, какую именно функцию нужно вызвать.

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

Во-вторых, для полиморфных объектов обычно требуется динамическое выделение памяти, поскольку размер объекта неизвестен на этапе компиляции. И если раньше разработчики игр практически всегда отключали rtti, то сейчас это норма, плюс нам пришлось его включить, когда мы начали использовать новую библиотеку для пользовательского интерфейса (WPFG) и её код стал пролезать по всей игре. В-третьих, виртуальные деструкторы усложняют управление памятью и могут привести к непредсказуемому поведению, но это отдельный случай.

Кто этот ваш CRTP вообще такой? CRTP - это паттерн программирования, при котором класс наследуется от шаблонного базового класса, передавая самого себя в качестве параметра шаблона.

Звучит сложно, но на практике это очень элегантное решение. Например: class Derived : public Base<Derived>. Базовый класс может вызывать методы производного класса через static_cast, при этом все вызовы разрешаются на этапе компиляции. Идеально подходит когда мы знаем все возможные типы на этапе компиляции и хотим избавиться от вызова виртуальных функций. CRTP позволяет создавать шаблоны функций и классов, которые работают с любыми типами, реализующими определенный интерфейс, но без накладных расходов виртуальных функций.

template <typename Derived>
class GameObject {
public:
    void update() {
        // Вызываем метод из производного класса через static_cast
        static_cast<Derived*>(this)->updateImpl();
    }

    void render() {
        static_cast<Derived*>(this)->renderImpl();
    }
};

class Player : public GameObject<Player> {
public:
    void updateImpl() {
        // Логика обновления игрока
        // "Updating Player position and state\n";
    }

    void renderImpl() {
        // "Rendering Player on screen\n";
    }
};

... а еще посыпьте статическим полиморфизмом

Альтернативный подход — через рантайм полиморфизм с использованием std::variant, при котором выбор конкретной реализации метода происходит на этапе компиляции, а не во время выполнения программы. Компилятор заранее знает, какую функцию нужно вызвать, и генерирует прямой вызов без промежуточных обращений к таблицам или указателям, что полностью устраняет накладные расходы времени выполнения, связанные с полиморфизмом. Все работает так же быстро, как если бы вы напрямую вызывали нужную функцию, и при этом код остается гибким и расширяемым — можно легко добавлять новые типы и реализации без изменения существующего кода. Я уже частично рассматривал эту тему в Game++. Heap? Less

struct ButtonEvent { 
    int button_id;
    void process() {} // "Обработка нажатия кнопки "
};

struct TimerEvent {
    int timer_id;
    void process() {} // "Обработка таймера"
};

struct NetworkEvent {
    std::string message;
    void process() {} // "Обработка сетевого события"
};

using Event = std::variant<ButtonEvent, TimerEvent, NetworkEvent>;

// Универсальный обработчик событий
struct EventProcessor {
    template<typename T>
    void operator()(T& event) const {
        event.process();
    }
};

Event e1 = ButtonEvent{42};
Event e2 = TimerEvent{7};
Event e3 = NetworkEvent{"Hello"};

std::visit(EventProcessor{}, e1);
std::visit(EventProcessor{}, e2);
std::visit(EventProcessor{}, e3);

... да промаринуйте в placement new и пулах

А еще часто нужно быстро создавать и уничтожать множество объектов — снаряды, эффекты, частицы, веревки и декали. Но вместо использования обычной динамической памяти будем применять статические пулы, что позволит перераспределять объекты без накладных расходов new/delete и фрагментации памяти, что особенно важно на консолях и мобильных устройствах.

gtl::pool<Projectile, 64> projectile_pool;
auto* proj = projectile_pool.allocate();

// Настраиваем и используем снаряд
proj->velocity = . . .;
proj->damage = . . .;

projectile_pool.deallocate(proj);

Или иногда важно точно контролировать порядок и время инициализации объектов, похоже на пул, но не пул. Представьте что такой подход можно использовать для стартап аллокаций и создавать уникальные для игры объекты — конфиги, системы, менеджеры рендера, звука и т.д.

alignas(GameConfig) char _gameConfigStorage[sizeof(GameConfig)];
GameConfig* config = new(_gameConfigStorage) GameConfig();

GameConfig->load_from_file({. . .});

// После использования вызываем деструктор вручную
// или не вызываем вообще, потому что это объект уровня жизни всей игры
GameConfig->~GameConfig();

template<typename T>
class GameResource {
    alignas(T) mutable uint8_t _data[sizeof(T)];
    mutable T* _instance = nullptr;

public:
    template<typename... Args>
    T& init(Args&&... args) const
    {
        if (_instance) {
            _instance->~T();
        }
        _instance = new (_data) T(std::forward<Args>(args)...);
        return *instance;
    }

    void destroy() const {
        if (_instance) {
            _instance->~T();
            _instance = nullptr;
        }
    }

    T& ref() const { 
        assert(_instance);
        return *_instance; 
    }
};

GameResource<GameConfig> g_config;

void Game::Init() {
    . . .
    g_config.init({100});
    . . .
}

... и отправьте на сертификацию

Одна из главных причин почему мы вдруг стали так пристально на использование памяти — жалобы со стороны новых игроков и, внезапно, отказ в сертификации на тестах, что знаете‑ли не очень приятно и порождает резонные вопросы у руководства компании.

На этапе сертификации тестовая лаборатория Sony зафиксировала низкий фпс, коррапшены в памяти и деградацию производительности — ага, а мы думали они там просто в билд играют, и отказала в одобрении игры для публикации. Это стало неприятным сюрпризом для всей команды и пришлось пересматривать архитектуру памяти, использовать фиксированные аллокаторы и практически полностью избавляться от динамических выделений памяти. От всех, конечно, не избавились, но сейчас на кадре остались сотни аллокаций в главном потоке, а было их, чего уж греха таить, на порядок больше.

Почему продавец хотдогов никогда сам их не ест?

В моем случае — продавец хотдогов ест их сам и вынужден кормить ими всю команду, но мышки плачут и жалуются:) Используя C++, не стоит «принимать» использование кучи как данность — можно и нужно строить архитектуру так, чтобы не использовать динамическое выделение памяти во время выполнения. Плюсы все еще позволяют пользоваться преимуществами новых стандартов — лямбдами, RAII, шаблонами и даже новыми функциями C++23 — при этом сохраняя предсказуемость работы системы.

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

Несмотря на очевидные технические преимущества, графики перфа, внутренние презентации, менторство — внедрение всего вышеописанного в реальных проектах часто сталкивается с сопротивлением команды. Многие привыкли к классическому ооп‑крестописанию с виртуальными функциями, наследованием и сахарным сахаром, который кажется им более интуитивным и понятным. Нескучное программирование требуют другого способа мышления о коде — нужно думать о типах, применении, шаблонной магии и вариантах объединения. И да, синтаксис становится более сложным, что поначалу может пугать и отталкивать.

К сожалению, практика показывает, что при первых же трудностях или дедлайнах команда быстро откатывается к знакомым привычкам. «Давайте лучше сделаем обычный интерфейс с виртуальными методами — это быстрее и все понимают» — не раз виденная автором реакция под давлением сроков.

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

Мне интересно услышать мнение Хабражителей и разработчиков на C++ — кто из вас строит проекты без кучи? Какие приёмы и стратегии помогли вам сохранить читаемость и понимаемость кода, не жертвую современными возможностями языка? Сталкивались ли вы с необходимостью убеждать команду в использовании таких практик?

P. S. Телегу рекламировать не буду, её у меня просто нет:)

P.P.S Приходите на вебинар про оптимизацию в GameDev! Расскажу про кастомные аллокаторы, а еще обсудим с коллегами из игростроя и PVS‑Studio практические советы по улучшению проектов и способах ускорить запуск мобильных игр.

25 сентября в 16:00 (MSK)
https://pvs‑studio.ru/ru/webinar

Комментарии (0)


  1. dersoverflow
    17.09.2025 16:02

    кто из вас строит проекты без кучи?

    ну я, например.

    использование mem_pool в 2008 году обеспечивало ускорение в десятки и СОТНИ раз: https://ders.by/cpp/mtprog/mtprog.html#3.1.1

    а сейчас еще есть и off_pool: 32-битные смещения вместо указателей обеспечивают адресацию 64 гигабайт: https://ders.by/cpp/deque/deque.html#7


  1. Jijiki
    17.09.2025 16:02

    я вот сейчас тестирую кучу в дереве и это круче просто кучи :)

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

    мелкие тесты

    Скрытый текст
    Генерация объектов: 1 мс
    Построение дерева: 182 мс
    hit
    Время поиска 1: 0 мс
    Test 1 (через сцену): hit
    Время поиска 2: 0 мс
    Обнаружено пересечений: 6
    Время обхода: 0 мс
    
    std::vector<Node> nodes;//тоесть это внутри дерева
    
    for (int i = 0; i < 3000; ++i)//создаём 3000 тыщи AABB
    
        Tree() : nodeCount(0), rootIndex(nullIndex) {
            nodes.reserve(6000);
    //при этом дерево имеет свои размеры внутренние узлы+желаемое количество обьектов

    просто вот просто на сухую говорить о пуле обьектов исключая из обсуждения пускай и возможно тайловую суть коллизий если игра 2д, там поидее красиво получается с деревом и она частично как мне кажется превратится в мемори-арена(просто потомучто в дереве будет весь функционал игровой)

    ну и наверху по калбеку делать сбор из дерева, как бы да прям цикле, а что есть альтернативы(bind или functional+bind)

    например не quicksort, а quickselect+kth туда же в копилку, а это разве не мемори-арена(мемори-арена это куча вроде удобная, но на 60 фпс могут быть промахи, соотв нужно 200 фпс для кешев), если дерево уже имеет всю арену выделенную

    пс из приятного нету sqrt вызова есть просто сравнение границ :)


  1. OlegMax
    17.09.2025 16:02

    рантайм полиморфизм с использованием std::variant, при котором выбор конкретной реализации метода происходит на этапе компиляции, а не во время выполнения программы

    Wat?! std::variant нужен в рантайме, оверхед я бы ожидал аналогичный вызовам виртуальных функций. Не понял, что у вас происходит в компайл тайм


    1. dalerank Автор
      17.09.2025 16:02

      Несложный вариант может развернуться в switch просто по индексам, первыми такую оптимизацию сделали разрабы кланга, потом подхватили остальные. У него внутри хранится индекс активного типа и сам объект, когда вызываеися visit стандартная реализация проходит через таблицу диспетчеризации, которая была сгенерирована на основе шаблонного кода. Почти все современные компиляторы (Clang и майки точно это делают) при включённых оптимизациях сворачивают это дело в обычный switch или даже jump-table по индексу. Зачемено на clang18+ если число вариантов не превышет 4.


  1. Kelbon
    17.09.2025 16:02

    Не знаю откуда у автора МИЛЛИСЕКУНДЫ на выделение памяти. Перестаньте уже демонизировать аллокатор. Он работает достаточно быстро, можете сделать бенчмарк и проверить, аллокация занимает около 30 наносекунд

    Конечно если у вас горячий цикл, имеет смысл оттуда убрать аллокацию, вопрос - на что вы её замените. Будет ли это быстрее. Будет ли это поддерживаемо

    Насчёт экономии памяти, т.е. тезис о том, что если вы пытаетесь использовать меньше кучу, то у вас программа ест меньше памяти - это ложный тезис. Наоборот, как только программа пытается меньше раз аллоцировать она начинает использовать буферы побольше, не деаллоцировать, а например складывать в фри лист и так далее. Это уже привозит и к фрагментации и к увеличению использования памяти в итоге. А самое глупое, что это приводит к тому что аллокация замедляется (сложнее аллоцировать, если много уже занято)


    1. dalerank Автор
      17.09.2025 16:02

      Исключительно практические примеры, 5к аллокаций на фрейме, слабый девайс уровня samsung a51 (средний фпс 42-45, 22-23ms), избавились примерно от половины аллокаций 2.5к-3к+ (средний фпс 50-55, 18-20ms). Ну т.е. это действительно миллисекунды на фрейм


      1. Jijiki
        17.09.2025 16:02

        а какие аллокации на фрейме появляются там надо просто взять адрес, частичек например, их же можно не выделять каждый раз, можно же выделить клиент 1 раз и обращаться к нему как к таблице по указателю или я что-то не понимаю? вот мы обсуждали как-то с вами, зачем создавать по новой сферу, взяли адрес примитива накинули цвет/размер, её адрес взят из таблицы например, воспользовались сферой убрали указатель, но она в таблице как примитив

        ну и получается весь лего закинули в клиент наверно, после загрузки из таблицы берем лего куски и в рендер


      1. Kelbon
        17.09.2025 16:02

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

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


        1. dalerank Автор
          17.09.2025 16:02

          Ну собственно об этом и статья, если можно не делать, то можно не делать :) Но вопрос "где деньги, Зин"? Мы же не бенчмарки показываем продакту, а фпс с устройства и графики с бордов. Другой вопрос даст ли следующее выпиливание половины оставшихся такой же буст. Вот в том большом проекте мы остановились на пяти сотнях на кадр - профита мало, проблем много... ну их, пусть живут


    1. pavlushk0
      17.09.2025 16:02

      И демонизировать виртульные методы, я бы добавил. Заметил тенденцию последних лет - все хаят традиционный рантайм полиморфизм, дескать там индерекшен, накладные расходы и т.д. (что конечно так и есть, но критично ли). И тянут (полезные но не всегда удобные) вариант и crtp. Посмотреть в исходники какого нибудь doom3, там virtual никого не смущало.


      1. Kelbon
        17.09.2025 16:02

        Не знаю откуда пошло поверье, что crtp хоть каким-то образом может заменить виртуальные функции. Это буквально инструмент для совсем другого. Да, согласен с вами в общем


  1. ArtMan99
    17.09.2025 16:02

    Фикс-vector и арены реально спасают


  1. AoD314
    17.09.2025 16:02

    Добавлю только, что алокация становится еще немного медленнее когда у тебя есть много(16+) потоков, которые так же выделяют и освобождают память.


  1. Kelbon
    17.09.2025 16:02

    Если программист пытается добавить элемент в уже заполненный контейнер

    это невозможно определить на компиляции. И почему-то совсем не упомянут факт, что ... не всегда известно максимальное число элементов на компиляции. Что тогда вы делаете?)

    А std::function тоже умеет хранить объект на стеке, если он удовлетворяет некоторым условиям

    немного CRTP

    В традиционном ООП на плюсах мы часто используем виртуальные функции

    CRTP не может заменить виртуальные функции. Это разные инструменты для совершенно разных задач.


    1. dalerank Автор
      17.09.2025 16:02

      да, не всегда это возможно, тогда мы переходим на pmr контейнеры, но сам факт (это исключительно я говорю про разработку игр) что вам пришлось вот посреди кадра выделять память через общий игровой аллокатор, значит гдето ошиблись с логикой. Нам не нужны тысячи и миллионы объектов, большая часть кода оперирует массивами не больше 128 элементов. Но есть и подсистемы, где массивы могут быть большими - и вот там уже нужно думать, как это обработать. Но повторюсь это относительно небольшой набор систем