Архитектура игр на C#: Глубокое погружение в разработку

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

1. Архитектура игрового цикла

Архитектура игрового цикла

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

Сердцем этой непрерывной работы является игровой цикл (game loop). Это фундаментальный архитектурный паттерн, который отделяет течение игрового времени от скорости работы процессора и пользовательского ввода.

> Назначение игрового цикла — сделать течение игрового времени независимым от ввода пользователя и скорости процессора. > > Game Programming Patterns

Анатомия базового цикла

В своей простейшей форме игровой цикл состоит из трех последовательных фаз, которые повторяются десятки или сотни раз в секунду:

  • Обработка ввода (Process Input): Сбор данных с клавиатуры, мыши, геймпада или сети. На этом этапе движок не двигает персонажа, он лишь регистрирует намерение (например, «нажата кнопка прыжка»).
  • Обновление состояния (Update): Применение бизнес-логики игры. Здесь рассчитывается гравитация, проверяются столкновения, обновляются таймеры способностей и работает искусственный интеллект врагов.
  • Рендеринг (Render): Отрисовка текущего состояния мира на экран и воспроизведение звуков.
  • !Схема классического игрового цикла

    Если мы реализуем это в лоб на C#, код будет выглядеть примерно так:

    У этого подхода есть фатальный архитектурный недостаток: скорость игры напрямую зависит от мощности железа.

    Представьте, что в методе Update() персонаж сдвигается на метр. На старом ноутбуке цикл успеет выполниться раз за секунду (30 FPS), и персонаж пройдет метров. На мощном игровом ПК цикл выполнится раза, и персонаж пролетит метра. Это разрушает игровой баланс и делает мультиплеер невозможным.

    Переменный шаг времени (Delta Time)

    Чтобы отвязать скорость игры от частоты кадров, разработчики ввели понятие переменного шага времени (variable time step), или (дельта тайм).

    — это время, прошедшее с момента предыдущего кадра.

    Теперь мы передаем это значение в метод обновления логики, и формула движения выглядит так:

    Где — новая позиция, — старая позиция, — скорость (в единицах в секунду), а — время кадра в секундах.

    Пример с числами: скорость персонажа метров в секунду.

  • На слабом ПК (30 FPS) сек. За один кадр персонаж сдвинется на метра.
  • На мощном ПК (100 FPS) сек. За один кадр персонаж сдвинется на метра.
  • В обоих случаях за одну реальную секунду персонаж пройдет ровно метров. Проблема решена?

    !Визуализация влияния FPS на физику

    Ловушка переменного шага

    К сожалению, переменный шаг времени создает новую, более скрытую проблему — недетерминированность физики.

    Физические движки используют сложные математические интегралы для расчета столкновений и отскоков. Когда постоянно скачет (например, , затем , затем ), результаты вычислений с плавающей запятой (floating-point) накапливают погрешности.

    Если выстрелить из одного и того же оружия под одним и тем же углом, при стабильных 60 FPS пуля попадет в цель, а при просадке до 45 FPS — пролетит мимо, потому что физический движок рассчитал траекторию с другим шагом времени. В соревновательных играх это недопустимо.

    Архитектурный стандарт: Полуфиксированный цикл

    Чтобы получить лучшее от обоих подходов (плавную картинку на любых мониторах и стабильную физику), современные движки, такие как Unity или Unreal Engine, используют паттерн накопителя (Accumulator).

    Суть подхода в том, чтобы отделить частоту обновления логики (которая всегда фиксирована) от частоты рендеринга (которая зависит от монитора и видеокарты).

    Сравним эти подходы:

    | Характеристика | Переменный шаг (Update) | Фиксированный шаг (FixedUpdate) | |---|---|---| | Частота вызовов | Зависит от FPS (может быть 30, 60, 144 в секунду) | Строго задана (например, ровно 50 раз в секунду) | | Что обрабатывать | Ввод игрока, анимации, UI, эффекты | Физика, перемещение, ИИ, сетевая синхронизация | | Стабильность | Низкая (зависит от лагов) | Абсолютная (детерминированная) |

    Реализация паттерна Accumulator на C#

    Давайте посмотрим, как это выглядит в коде ядра игрового движка. Это именно та архитектура, которая работает «под капотом».

    Разберем этот код по шагам, так как это важнейший концепт для архитектора игр:

  • Переменная lag накапливает реальное время, которое прошло с прошлого кадра.
  • Внутренний цикл while (lag >= MS_PER_UPDATE) — это сердце системы. Если у игрока мощный ПК и кадр отрендерился за мс, lag будет равен . Условие ложно, поэтому FixedUpdate не вызовется. Движок просто отрисует кадр.
  • На следующем кадре пройдет еще мс. lag станет . Снова не хватает.
  • На третьем кадре пройдет еще мс. lag станет . Теперь . Движок вызовет FixedUpdate один раз, отнимет от lag (останется ) и пойдет рендерить.
  • Таким образом, независимо от того, выдает ли компьютер 300 FPS или 20 FPS, метод FixedUpdate вызовется ровно 60 раз за одну реальную секунду.

    Спираль смерти (Spiral of Death)

    У архитектуры с накопителем есть одна уязвимость. Что произойдет, если метод FixedUpdate содержит слишком тяжелую логику (например, спавн 10 000 врагов) и выполняется дольше, чем MS_PER_UPDATE?

    Допустим, MS_PER_UPDATE = мс, но само выполнение FixedUpdate занимает мс. Реальное время (elapsedTime) будет огромным. Накопитель lag быстро переполнится. Внутренний цикл while попытается вызвать FixedUpdate несколько раз подряд, чтобы «догнать» реальное время. Но каждый вызов занимает мс, что генерирует еще больше реального времени!

    Игра зависает намертво, пытаясь симулировать время, которое она сама же и тратит. Это называется Спиралью смерти. Архитектурное решение этой проблемы — введение предохранителя: ограничение максимального количества вызовов FixedUpdate за один кадр (например, не больше 5). Если игра лагает сильнее, мы просто замедляем внутреннее игровое время (эффект slow-motion), но спасаем движок от зависания.

    Интерполяция рендеринга

    Внимательный разработчик заметит аргумент lag / MS_PER_UPDATE в методе Render(). Зачем он нужен?

    Представьте, что lag остался равен мс (ровно половина от шага обновления). Это значит, что физически объекты находятся между двумя состояниями. Если мы просто отрисуем их текущие координаты, на мониторах с высокой герцовкой (144 Гц) движение будет казаться дерганым (stuttering), так как рендер будет показывать одну и ту же позицию несколько кадров подряд, пока не сработает следующий FixedUpdate.

    Значение lag / MS_PER_UPDATE дает нам коэффициент от до (в нашем случае ). В подсистеме рендеринга мы используем этот коэффициент (часто называемый alpha), чтобы визуально предсказать позицию объекта:

    Это позволяет отвязать логику от графики: физика может работать при 30 FPS, а рендеринг — при 144 FPS, и движение будет абсолютно плавным. Именно так устроена архитектура сетевых шутеров (например, CS:GO или Valorant), где сервер считает физику на низком тикрейте (tickrate), а клиент интерполирует картинку под частоту монитора игрока.

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

    2. Компонентная модель и ООП

    Компонентная модель и ООП

    В предыдущей статье мы спроектировали сердце игрового движка — игровой цикл, который непрерывно обновляет состояние мира. Теперь возникает следующий архитектурный вопрос: как именно описывать объекты, живущие в этом мире?

    Исторически первым инструментом, который программисты применяли для описания игровых сущностей, было классическое объектно-ориентированное программирование (ООП). Однако при разработке сложных игр базовые принципы ООП, в частности наследование, быстро превращаются из помощника в архитектурную ловушку.

    Иерархическая ловушка классического ООП

    Представьте, что вы проектируете ролевую игру. Следуя парадигме ООП, вы начинаете выстраивать иерархию классов на основе отношения «является» (Is-A).

    В корне находится базовый класс GameObject. От него наследуется Character (персонаж), который умеет двигаться и получать урон. От Character наследуются Player (игрок) и Enemy (враг). Пока всё выглядит логично.

    Затем геймдизайнер просит добавить в игру деревянный ящик. Ящик не умеет ходить, поэтому он не может наследоваться от Character. Вы создаете класс Prop (реквизит), наследуете его от GameObject и добавляете ему здоровье, чтобы ящик можно было сломать.

    Уже на этом этапе появляется дублирование логики получения урона. Но настоящий кошмар начинается, когда геймдизайнер просит добавить магическую живую бочку, которая стоит на месте (как Prop), но кусает игрока, если к ней подойти (как Enemy), при этом она бессмертна (не имеет Health).

    В попытках решить эту проблему через наследование, разработчики часто приходят к антипаттерну God Object (Божественный объект). Они переносят всю возможную логику (здоровье, движение, ИИ, звук, физику) в самый базовый класс GameObject, а в наследниках просто включают или выключают нужные флаги.

    > Система «всё в одном», где некий класс объекта содержит в себе все возможные данные: физику, графику, звук — это самый простой вариант. В этой же кажущейся простоте кроется главный недостаток — полное отсутствие гибкости. > > GameDev.ru

    Такой класс разрастается до десятков тысяч строк кода. Любое изменение в логике движения ломает инвентарь, а добавление нового типа врага требует переписывания базового класса. Архитектура становится жесткой и хрупкой.

    Принцип композиции

    Решением этой проблемы стал фундаментальный принцип проектирования: предпочитайте композицию наследованию (Favor composition over inheritance).

    Вместо того чтобы спрашивать «Чем является этот объект?», мы спрашиваем «Из чего состоит этот объект?» (отношение Has-A).

    В этой парадигме любой игровой объект — это просто пустой контейнер (сущность), который приобретает свойства и поведение только благодаря тому, какие детали (компоненты) в него положены.

    !Сравнение иерархии наследования и компонентной модели

    Анатомия компонентной модели

    Компонентно-ориентированный подход (КОП) делит архитектуру на две основные сущности:

  • Entity (Сущность / Игровой объект): Уникальный идентификатор в игровом мире. В простейшем виде это класс, содержащий список или словарь прикрепленных к нему компонентов.
  • Component (Компонент): Изолированный блок данных и логики, отвечающий за одну узкую задачу. Например: Transform (позиция), Health (здоровье), MeshRenderer (отрисовка), PlayerInput (чтение кнопок).
  • Сравним подходы на практике:

    | Характеристика | Классическое наследование (ООП) | Компонентная модель (КОП) | |---|---|---| | Создание нового врага | Написание нового класса, поиск места в иерархии | Сборка из существующих компонентов (как конструктор Lego) | | Изменение поведения в рантайме | Невозможно (класс фиксирован при компиляции) | Легко (удаление одного компонента и добавление другого) | | Переиспользование кода | Низкое (логика заперта в ветках наследования) | Максимальное (компонент Health одинаков для игрока и бочки) |

    Реализация под капотом

    Как архитектор, вы должны понимать, как это работает на уровне кода. Базовая реализация сущности в C# выглядит как словарь, где ключом выступает тип компонента, а значением — сам компонент. Это обеспечивает скорость поиска компонента за время .

    Теперь создание игрока выглядит не как вызов конструктора new Player(), а как сборка:

    !Интерактивный конструктор сущностей

    Проблема связности компонентов

    Когда мы разбиваем монолитный класс на десятки мелких компонентов, возникает новая архитектурная проблема: как им общаться друг с другом?

    Компонент PlayerInput считывает нажатие пробела. Как компонент PhysicsBody узнает, что нужно применить силу для прыжка? Если PlayerInput будет напрямую вызывать GetComponent<PhysicsBody>().Jump(), мы получим жесткую связность (tight coupling). Компонент ввода больше нельзя будет использовать для управления космическим кораблем, у которого нет физического тела для прыжка.

    Существует два архитектурных паттерна для решения этой проблемы:

    1. Шина событий (Event Bus / Messages)

    Компоненты не знают друг о друге. Они общаются через отправку сообщений самой сущности.

    Компонент ввода отправляет сообщение: «Я хочу прыгнуть». Сущность рассылает это сообщение всем своим компонентам. Если среди них есть PhysicsBody, он реагирует на сообщение и выполняет прыжок. Если физического тела нет — сообщение просто игнорируется. Никаких ошибок и нулевых ссылок.

    2. Разделение данных и логики

    Более продвинутый подход, который часто используют опытные разработчики в Unity — это вынесение данных за пределы компонентов логики.

    Вместо того чтобы хранить текущее здоровье внутри HealthComponent, мы создаем отдельный объект данных (в Unity это часто реализуется через ScriptableObject или простые C# классы).

    Компонент DamageReceiver (получатель урона) при попадании пули просто уменьшает значение в объекте данных HealthData. А компонент UIHealthBar (полоска здоровья на экране) каждый кадр читает это значение из HealthData.

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

    Границы применимости

    Компонентная модель — это мощный инструмент, но она не отменяет ООП полностью. Внутри самих компонентов вы по-прежнему используете инкапсуляцию, полиморфизм и интерфейсы.

    Однако классический КОП (где компонент содержит и данные, и методы Update) имеет предел производительности. Когда на сцене появляются десятки тысяч объектов, вызовы методов Update у каждого компонента начинают тормозить процессор из-за промахов кэша памяти (cache misses).

    Для решения проблем экстремальной производительности индустрия шагнула еще дальше — к архитектуре Entity Component System (ECS), где компоненты становятся чистыми структурами данных (struct) без логики, а логика выносится в глобальные Системы. Но чтобы понять ECS, необходимо в совершенстве овладеть базовой компонентной моделью, которую мы разобрали сегодня.

    3. Паттерны проектирования в геймдеве

    Паттерны проектирования в геймдеве

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

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

    Иллюзия простоты: Паттерн Singleton

    Самый известный и одновременно самый опасный паттерн в разработке игр — Singleton (Одиночка). Его задача — гарантировать, что у класса есть только один экземпляр, и предоставить к нему глобальную точку доступа.

    В играх часто существуют уникальные системы: менеджер аудио, контроллер сохранений или глобальный менеджер состояния игры (GameManager).

    Кажется, что это невероятно удобно. Любой враг при смерти может просто вызвать GameManager.Instance.AddScore(10). Доступ к данным происходит за время . Но за эту простоту приходится платить высокую цену.

    > Спагетти-код не соответствует духу SOLID. Методы и классы — длинные и сложные; их нельзя легко изменить, не сломав другие части приложения. Нельзя изолировать кусок кода, не потянув за собой остальное приложение. > > Хабр

    Проблема Singleton заключается в скрытых зависимостях. Когда вы смотрите на конструктор или публичные методы класса Enemy, вы не видите, что он зависит от GameManager. Если вы захотите перенести этого врага в другую игру или написать для него unit-тест, код сломается, потому что глобальный менеджер не инициализирован.

    Вместо глобальных точек доступа профессиональные разработчики предпочитают передавать зависимости явно (через конструкторы) или использовать паттерн Inversion of Control (IoC) и внедрение зависимостей (Dependency Injection), где специальный контейнер сам связывает нужные системы при старте игры.

    Управление памятью: Object Pool

    В языках с автоматическим управлением памятью, таких как C#, создание новых объектов через оператор new выделяет память в управляемой куче (Managed Heap). Когда объект больше не нужен, в дело вступает Сборщик мусора (Garbage Collector, GC). Он сканирует память, находит «мертвые» объекты и освобождает место.

    В геймдеве это критическая проблема. Представьте космический шутер. Игрок стреляет из пулемета со скоростью 20 пуль в секунду. Враги тоже стреляют. Каждую секунду создаются и уничтожаются десятки объектов.

    Работа сборщика мусора требует времени. Если GC решит очистить память прямо во время перестрелки, процессор остановит выполнение игрового цикла. При целевой частоте кадров в секунду на один кадр отводится всего мс. Если сборка мусора займет мс, игрок увидит заметный рывок (фриз).

    Решением является паттерн Object Pool (Пул объектов).

    Вместо того чтобы создавать и уничтожать объекты на лету, мы заранее (при загрузке уровня) создаем массив неактивных объектов. Когда игроку нужна пуля, мы берем её из пула, перемещаем в дуло пистолета и активируем. Когда пуля попадает в стену, мы не удаляем её из памяти, а просто деактивируем и возвращаем в пул.

    | Характеристика | Создание (Instantiate/Destroy) | Пул объектов (Object Pool) | |---|---|---| | Выделение памяти | Постоянное, непредсказуемое | Единоразовое при загрузке | | Нагрузка на CPU | Высокая (работа GC) | Минимальная (переключение флагов) | | Сложность кода | Низкая | Средняя (нужно сбрасывать состояние объекта) |

    !Интерактивная симуляция паттерна Object Pool

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

    Поведенческая архитектура: State (Конечный автомат)

    Поведение игровых сущностей часто зависит от их текущего состояния. Игрок может стоять, бежать, прыгать или атаковать. Начинающие программисты обычно реализуют это через огромные блоки switch или if-else внутри метода Update.

    С добавлением новых механик (двойной прыжок, скольжение, плавание) этот код превращается в неуправляемого монстра. Одно неверное условие — и игрок сможет атаковать мечом во время плавания.

    Паттерн State (Состояние) решает эту проблему, инкапсулируя каждое состояние в отдельный класс. Мы создаем интерфейс IState с методами Enter(), Update() и Exit().

    Теперь логика бега живет только в классе RunState, а логика прыжка — в JumpState. Главный контроллер персонажа (Context) просто хранит ссылку на текущее состояние и каждый кадр вызывает у него метод Update().

    Переход между состояниями становится явным: чтобы начать прыжок, контроллер вызывает currentState.Exit(), меняет ссылку на JumpState и вызывает newState.Enter(). Это полностью исключает конфликты логики.

    Развязка систем: Observer (Наблюдатель)

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

    Представьте, что игрок получает урон. На это событие должны отреагировать:

  • Интерфейс (уменьшить полоску здоровья).
  • Аудиосистема (проиграть звук боли).
  • Система достижений (выдать ачивку «Первая кровь»).
  • Система эффектов (брызги крови на экране).
  • Если класс Player будет напрямую вызывать методы всех этих систем, он станет жестко связан с ними. Удаление системы достижений из проекта приведет к ошибкам компиляции в классе игрока.

    Паттерн Observer меняет направление зависимости. Игрок (Субъект) ничего не знает о других системах. Он просто «кричит» в пустоту: «Я получил 20 урона!».

    Другие системы (Наблюдатели) заранее подписываются на это событие и реагируют на него самостоятельно.

    !Схема работы паттерна Наблюдатель (Observer)

    В C# этот паттерн встроен на уровне языка через делегаты и ключевое слово event:

    Любая внешняя система может подписаться на это событие (player.OnDamageTaken += PlayOuchSound;). Это делает архитектуру модульной: вы можете добавлять или удалять наблюдателей в любой момент времени, не меняя ни строчки кода в самом классе игрока.

    Паттерны проектирования — это не строгие правила, а инструменты. Опытный архитектор знает, что чрезмерное увлечение паттернами (Overengineering) так же губительно, как и их полное отсутствие. Искусство разработки игр заключается в том, чтобы применять правильный паттерн ровно в тот момент, когда сложность системы начинает выходить из-под контроля.

    4. Взаимодействие игровых объектов

    Взаимодействие игровых объектов

    Представьте, что вы разрабатываете космический шутер. На экране одновременно находятся корабль игрока, сотня вражеских истребителей и тысяча летящих лазерных лучей. Каждый кадр игра должна отвечать на критически важный вопрос: попал ли какой-нибудь лазер в какой-нибудь корабль?

    Самый очевидный подход для начинающего программиста — взять каждый лазер и проверить его расстояние до каждого корабля. Если в игре объектов, то количество проверок вычисляется по формуле комбинаторики для пар:

    Где — количество проверок, а — общее количество объектов на сцене.

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

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

    Пространственное разбиение (Spatial Partitioning)

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

    Для 2D-игр стандартом индустрии является структура данных Quadtree (Квадродерево), а для 3D-игр — Octree (Октодерево).

    Принцип работы квадродерева:

  • Весь игровой мир представляет собой один большой квадрат (корневой узел).
  • Как только в этом квадрате оказывается больше объектов, чем заданный лимит (например, больше 4), квадрат делится на 4 равных подквадрата.
  • Объекты распределяются по новым подквадратам.
  • Процесс повторяется рекурсивно: если в маленьком подквадрате снова скапливается много объектов, он тоже делится на 4 части.
  • !Интерактивная визуализация квадродерева (Quadtree)

    Теперь, когда лазер летит через пространство, движок запрашивает у квадродерева: «Дай мне список объектов, которые находятся в том же самом маленьком квадрате, что и лазер». Вместо проверок движок делает всего . Это снижает сложность алгоритма с до , что позволяет современным играм обрабатывать десятки тысяч объектов одновременно.

    Пайплайн физического движка: Широкая и Узкая фазы

    Даже когда мы сузили круг подозреваемых с помощью пространственного разбиения, точная проверка столкновения двух сложных 3D-моделей (мешей) — это невероятно тяжелая математическая задача. Модель персонажа может состоять из полигонов. Проверять пересечение каждого полигона с каждым полигоном стены — непозволительная роскошь.

    Поэтому физический движок делит проверку на две фазы.

    Широкая фаза (Broad Phase)

    На этом этапе движок использует максимально упрощенные невидимые оболочки вокруг объектов — Bounding Volumes (Ограничивающие объемы). Самый популярный тип — AABB (Axis-Aligned Bounding Box, выровненный по осям ограничивающий параллелепипед).

    Это просто прямоугольник (или коробка в 3D), который жестко привязан к осям координат (не вращается вместе с объектом) и полностью вмещает в себя объект.

    Проверка пересечения двух AABB в 2D-пространстве требует всего четырех простых логических сравнений:

    и и и

    Где — левая граница первого объекта, — правая граница второго объекта и так далее. Если все четыре условия истинны, коробки пересекаются. Если хотя бы одно ложно — объекты гарантированно не касаются друг друга, и дальнейшие расчеты прекращаются.

    Узкая фаза (Narrow Phase)

    Только если широкая фаза показала пересечение AABB, движок переходит к узкой фазе. Здесь в дело вступают точные Коллайдеры (Colliders).

    Коллайдер — это математическая форма, аппроксимирующая объект. Это может быть сфера (SphereCollider), капсула (CapsuleCollider — идеально для гуманоидных персонажей) или точный полигональный меш (MeshCollider).

    !Разница между широкой и узкой фазами проверки столкновений

    > Определять положение всех объектов друг относительно друга нужно в течение одного вызова отрисовки, то есть всего за 20-30 мс. За это время должна рассчитаться вся графика и физика в кадре, чтобы игра работала в стабильных 30FPS. > > Media XYZ

    Архитектура намерений: Коллайдеры против Триггеров

    Когда узкая фаза фиксирует пересечение, движок должен решить, что делать дальше. С архитектурной точки зрения взаимодействия делятся на два принципиально разных типа.

    | Характеристика | Физическое столкновение (Collider) | Логическое пересечение (Trigger) | |---|---|---| | Реакция движка | Вычисляет импульс, отталкивает объекты, гасит скорость | Игнорирует физику, объекты проходят насквозь | | Пример из игры | Мяч отскакивает от стены, машина врезается в столб | Игрок входит в зону ядовитого газа, подбирает аптечку | | Нагрузка на CPU | Высокая (расчет массы, трения, упругости) | Низкая (только генерация события) |

    Триггеры — это основа игровой логики. Зона видимости врага, чекпойнт, область открытия автоматических дверей — всё это триггеры. В коде на C# (например, в Unity) это обрабатывается через систему событий, основанную на паттерне Observer, который мы разбирали ранее:

    Проектирование взаимодействия через интерфейсы

    Самая частая ошибка при проектировании взаимодействия — жесткая привязка к тегам или конкретным классам.

    Представьте, что вы делаете выстрел (используя Raycast — пускание луча). Луч попадает в объект. Начинающий разработчик напишет так:

    Этот код нарушает принцип открытости/закрытости (Open/Closed Principle) из SOLID. Каждый раз, когда вы добавляете в игру разрушаемый ящик, стеклянную витрину или босса, вам придется дописывать новые else if. Код быстро станет нечитаемым.

    Правильный архитектурный подход — использование Интерфейсов.

    Интерфейс в C# — это контракт. Он гарантирует, что класс, который его реализует, имеет определенные методы, но скрывает детали их реализации.

    Создадим интерфейс IDamageable:

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

    Оружию абсолютно всё равно, во что оно попало: в орка, в деревянную дверь или в космический корабль. Если объект подписал контракт IDamageable, оружие просто передает ему урон. Это называется полиморфизмом.

    Такой подход позволяет создавать глубокие системные игры (Immersive Sims). Вы можете создать интерфейс IFlammable (воспламеняемый). Огненный шар при попадании будет искать этот интерфейс. Если вы повесите реализацию IFlammable на деревянный ящик, он сгорит. Если повесите на врага — он загорится. Если на лужу масла — она вспыхнет. И всё это без единого изменения в коде самого огненного шара.

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

    5. Управление памятью в C# для игр

    Управление памятью в C# для игр

    Представьте, что ваша игра работает идеально: логика выверена, физика просчитывается через пространственные деревья, а архитектура построена на гибких интерфейсах. Вы запускаете проект на консоли или мобильном устройстве, играете 10 минут, и вдруг картинка замирает на долю секунды. Затем еще раз. И еще. Частота кадров падает, а устройство начинает греться.

    В 90% случаев причина кроется не в сложной графике, а в том, как ваш код работает с оперативной памятью. В отличие от языков вроде C++, где программист обязан вручную выделять и освобождать каждый байт, C# берет эту рутину на себя. Это невероятно ускоряет разработку, но создает иллюзию, что о памяти можно забыть. Для создателя высоконагруженных игровых систем эта иллюзия фатальна.

    Чтобы понять, почему игра «фризит», необходимо заглянуть под капот среды выполнения .NET и разобраться, где именно живут ваши переменные.

    Анатомия памяти: Стек и Куча

    Когда вы запускаете игру, операционная система выделяет процессу блок оперативной памяти. Среда выполнения C# (CLR или IL2CPP в случае Unity) делит эту память на две принципиально разные области: Стек (Stack) и Кучу (Heap).

    Стек: Быстрый блокнот

    Стек работает по принципу LIFO (Last In, First Out — последним пришел, первым ушел). Это очень быстрая, но ограниченная в объеме область памяти. Сюда попадают значимые типы (Value Types): примитивы вроде int, float, bool, а также структуры (struct).

    Когда вызывается метод, все его локальные переменные складываются в стек друг за другом. Как только метод завершает работу, его переменные мгновенно «стираются» — указатель стека просто сдвигается назад.

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

    Куча: Огромный склад

    Куча — это обширная область памяти для хранения ссылочных типов (Reference Types): классов (class), массивов, строк и делегатов.

    Когда вы пишете new Enemy(), происходит следующее:

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

    Сборщик мусора и проблема Stop-The-World

    Поскольку память на устройстве физически ограничена, кучу нужно регулярно чистить от объектов, которые больше не используются. Этим занимается Сборщик мусора (Garbage Collector, GC).

    GC работает как детектив. Он строит ориентированный граф всех объектов в памяти. Поиск начинается с Корней (Roots) — статических переменных и активных локальных переменных в стеке. Если от корня можно по ссылкам дойти до объекта в куче, объект считается «живым». Если связь оборвана — объект признается мусором, и занимаемая им память освобождается.

    !Схема работы Сборщика мусора

    Проблема в том, что в момент очистки памяти Сборщик мусора вынужден приостановить выполнение всех остальных потоков программы. Это явление называется Stop-The-World (Остановка мира).

    В обычных приложениях пауза в 20-30 миллисекунд незаметна для пользователя. В играх это катастрофа.

    Рассчитаем бюджет времени на один кадр при целевой частоте 60 FPS:

    Где — время отрисовки одного кадра в миллисекундах, а — количество кадров в секунду.

    При 60 FPS у процессора есть всего мс на то, чтобы обработать ввод игрока, просчитать физику, обновить ИИ и подготовить графику. Если в середине этого процесса просыпается Сборщик мусора и забирает 10 мс на сканирование кучи, игра не успевает отрисовать кадр вовремя. Игрок видит рывок (фриз).

    Скрытые враги: Упаковка и строки

    Даже зная о куче, начинающие разработчики часто генерируют мусор неосознанно. Самый коварный механизм в C# — это Упаковка (Boxing).

    Упаковка происходит, когда значимый тип (например, int из стека) приводится к ссылочному типу (например, object или интерфейсу). Поскольку ссылочные типы обязаны жить в куче, среда выполнения незаметно создает там объект-обертку и копирует туда значение.

    Рассмотрим классическую ошибку в игровом цикле:

    Строки в C# неизменяемы (immutable). При сложении строки и числа, число сначала упаковывается в кучу (Boxing), затем создается совершенно новый объект строки. Старая строка становится мусором. При 60 FPS этот код будет создавать 120 мертвых объектов в секунду. Через пару минут куча переполнится, придет Сборщик мусора, и игра «запнется».

    Фрагментация памяти

    Помимо пауз, постоянное выделение и удаление объектов в куче приводит к Фрагментации (Fragmentation).

    Представьте парковку. Машины (объекты) приезжают и уезжают. Со временем между припаркованными машинами образуются пустые места разного размера. Приезжает длинный автобус (большой массив данных). На парковке суммарно достаточно свободного места, но нет ни одного сплошного куска нужной длины. Автобус не может припарковаться.

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

    !Интерактивная визуализация фрагментации памяти

    Именно поэтому в геймдеве так популярен паттерн Object Pool (Пул объектов), который мы разбирали ранее. Вместо того чтобы создавать и уничтожать лазерные лучи в куче, мы создаем их один раз при загрузке уровня, а затем просто включаем и выключаем, не трогая память.

    Архитектурное решение: Локальность данных и Структуры

    Как профессиональные архитекторы борются с нагрузкой на память? Они меняют подход к проектированию данных, отдавая предпочтение структурам (struct) вместо классов (class).

    | Характеристика | Класс (class) | Структура (struct) | |---|---|---| | Где хранится | В куче (Heap) | В стеке (Stack) или внутри другого объекта | | Передача данных | По ссылке (указатель) | По значению (полное копирование) | | Нагрузка на GC | Создает мусор при удалении | Не отслеживается Сборщиком мусора | | Использование в играх | Сложные системы, менеджеры, ИИ | Векторы, цвета, математика, компоненты |

    Использование структур дает колоссальное преимущество в производительности благодаря Локальности данных (Data Locality).

    Процессор компьютера работает намного быстрее, чем оперативная память (RAM). Чтобы не ждать данные из RAM, процессор загружает их небольшими порциями в свой сверхбыстрый кэш (L1/L2/L3). Процессор всегда загружает данные сплошным блоком.

    Если у вас есть массив классов Enemy, сам массив хранит только ссылки. Сами объекты врагов разбросаны по всей куче в случайных местах. Процессор загружает блок памяти, находит там ссылку, идет по ней в другой конец оперативной памяти, ждет, читает данные. Это называется Cache Miss (промах кэша).

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

    > В разработке для консолей с ограниченными ресурсами, архитектура с частыми аллокациями не просто неэффективна — она становится реальной угрозой для стабильности проекта. Каждое выделение памяти в куче влечёт за собой накладные расходы. > > Хабр

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