
У меня есть книга, которая называется Game++ и несколько статей, где я разбирал какие паттерны применяются в играх и движках. В книге почти сто страниц отведено про эти самые паттерны и подробно рассказано какие они бывают, как выглядят в C++, где у них подводные камни и как их применять. Т.е. ровно те мелочи реализации, которые обычно интересно перечитать, когда вы в очередной раз решаете делать фабрику отдельным классом или попробовать обойтись std::function. Когда я её писал, мне казалось, что это будет очень полезный практический текст, и он таким и получился, и человек с опытом довольно быстро находит там нужное.
Но если читать книгу целиком, а не эти отдельные главы, то хорошо видно, как я по неопытности и уверенности молодого автора в собственной правоте взял с места в карьер и сразу начал рассказывать про реализации, как будто читатель уже всё для себя решил и его интересует только синтаксис, предположив, что мы все тут делаем условный AAA-движок, в котором сериализация неизбежна, а скрипты обязательны. В результате получился классический случай, когда книжка отвечает на вопрос «как», но обходит вопрос «а собственно зачем», а без ответа на него все ответы про «как» оказываются либо случайно-полезными, либо системно-вредными, потому что человек берёт оттуда подход, переносит его в проект или прикручивает к своей мини-игре и потом жалуется, что у него теперь полторы тысячи строк инфраструктуры на ту же мини-игру, а работает она ровно так же, как раньше, только медленнее.
Эта статья родилась из попытки эту ошибку признать и исправить, и заодно из очередного перечитывания GoF, которую я первый раз увидел ещё студентом, и тогда мне она показалась слишком абстрактной и недостаточно практической. И теперь хочу вернуться на пару шагов назад и пройти этот путь правильно, начав не с того, как реализован паттерн, а с того, какую инженерную проблему он вообще решает, и в каких условиях он становится либо спасением проекта, либо лишним слоем абстракции, который дорого стоит и ничего не даёт.
Если вам вдруг надоест читать эти 106 минут, там в конце есть TL;DR секция, где собрано краткое описание.
Зачем вообще нужны паттерны
Большинство разговоров о паттернах начинается с того, что кто-то берёт GoF, открывает на случайной странице и говорит, что вот этот <подставьте свое>, мол, единственно правильный способ сделать фабрику или наблюдателя, и дальше принимается обсуждать, виртуальный там должен быть метод или невиртуальный, а если у нас компилятор еще не поддерживает рефлексию, то как мы тогда вообще будем жить. Это очень похоже на ситуацию, когда вы покупаете молоток в строительном магазине, а вам долго и подробно объясняют, с каким именно гвоздём в каком именно дереве он лучше всего работает, не уточнив сначала, дом вы строите или скворечник.
Первый опасность паттерн то, в реальной жизни это не правильный ответ на то какого размера скворешник мы будем строить на этот раз, а вполне себе технический компромисс между несколькими силами. Эти силы тянут вашу архитектуру в разные стороны, и без такого компромисса архитектуру просто порвет и никакие паттерны ей уже не помогут. Вторая, что когда вы читаете про «избегание изменений» или «слабую связанность», вам пытаются продать одну сторону медали, и довольно часто продают её, хотя в вашей конкретной игре это вообще не нужно.
Силы при этом бывают как вполне технические, вроде связанность против связности, кеш-локальности против гибкости, header hell против forward declaration и много чего другого, что довольно хорошо описано в учебниках. А бывают силы организационные, про которые в учебниках почти ничего не пишут, и которые в реальном проекте оказываются сильнее всех технических вместе взятых. Например, сколько у вас людей, какой у них опыт, легко ли договариваются, как часто будут меняться требования геймдизайна, придёт ли локализатор за китайским языком, и сколько раз техлид успеет посмотреть код движка и захотеть «вот это, только лучше».
Когда вы выбираете паттерн, вы на самом деле отвечаете на вопрос, где именно вы готовы заплатить и чем именно вы будете расплачиваться, и в зависимости от ответа правильным окажется либо тяжёлый data-driven с генерацией хедеров по схеме, либо плоский C++ файл на тысячу строк, где всё пишется руками и просто работает. Это условное упрощение.
Отсюда cтановитс понятно, почему вопросы вида «какая архитектура GUI самая лучшая» или «что выбрать, ECS или иерархию объектов» технически бессмысленны, ровно как и слово «универсальный» в описании движка, потому что у универсальности есть конкретная цена, и она измеряется в мегабайтах исходников, человеко-годах поддержки и количестве не написанных вами игр. Тим Суини как то высказал мысль, что если вы видите универсальный движок объёмом меньше ста мегабайт исходников, примерно столько весит Unreal, смело проходите, потому что весь код, который не написан в универсальном движке, придётся писать вам.
С другой стороны, тот же Unreal Engine, который размер сотни мегабайт давно превысил, тоже не универсальный, он построен под вполне конкретные жанры с конкретной моделью симуляции и репликации, и попытка собрать на нём, скажем, RTS уровня StarCraft II с её определёнными правилами синхронизации заканчивается либо переписыванием половины движка, либо переездом на что-то другое, но отговаривать вас попробовать это сделать, я, конечно, не буду.
Очень полезно держать в голове несколько прикладных примеров, на которых эта мысль становится очень наглядной. Когда id Software писали первый DOOM, у них было три программиста на все кор-системы, один компилятор, один таргет и очень понятные ограничения железа. Любой data-driven дизайн с генерацией кода по схеме был бы для них ровно тем же оверинжинирингом, и поэтому в исходниках лежит плотный, оптимизированный под кэш C-код, который очень тяжело расширить на что-то другое, зато легко читать.
id Software на момент DOOM не была «двумя людьми», а уже сложившейся компанией с 8 сотрудниками (Carmack, Romero, Hall, Petersen, два художника Carmack+Cloud, дизайнер). «Два программиста» тут скорее легенда, которую часто цитируют, но это история больше про Wolfenstein 3D (1992), когда движок писал Carmack один, плюс помогал Romero.
Вторая сторона медали, это BioWare, которые пятнадцать лет спустя делали Dragon Age. На тот момент у них были четыре десятка геймдизайнеров, локализация на десяток языков и почти сотня программистов движка и игры, и такой же подход с C-кодом гарантированно бы убил проект задолго до релиза. Поэтому команда сначала два года писала редактор, схемы и сериализацию, заплатив в инфраструктуре в десятки раз больше, чем id, но получив возможность нанимать дизайнеров, которые вообще не видяли и строчки сишного кода. Обе команды сделали правильный выбор, при этом выборы у них прямо противоположные, и это тоже нормально, потому что финальные цели у проектов были разные.
Поэтому полезнее всего относиться к паттернам не как к рецептам или словарю заклинаний, которые надо выучить и кидать в код, а как к незаконченному обсуждению и ровно тогда, когда вам нужно с тимлидом, с архитектором соседней подсистемы или с собой через полгода обговорить, какой именно компромисс вы выбрали и почему, паттерны начинают экономить время и нервы. И вместо часовой импровизации с маркером у доски вы говорите «у нас тут MVC, потому что вью переписывают чаще модели», право собеседник согласиться и всем пойти пить кофе на тераску, либо аргументированно возразить и тогда спор движется к решению, а не по кругу. Всё остальное про паттерны это уже технические детали, которые очень сильно зависят от языка, и о которых дальше речь и пойдёт.
Layers (слои)

Слой это не просто папка в проекте или собранная блиотека а, вполне конкретное архитектурное обещание команды самой себе: «мы договариваемся, что вот этот код общается с миром только через вот этот фасад, ничего другого не использует, ни в кого выше себя не заглядывает, и если завтра нам захочется его выкинуть и переписать с нуля, мы выкинем и перепишем, никого об этом не предупреждая, потому что мы никому ничего не должны, кроме фасада».
В идеале получается классический слоёный пирог, в котором каждый слой видит только сервисы нижележащего, и в результате на белой доске рисуется красивая ровная вертикаль, которую очень любят показывать на конференциях, в книжках и на собраниях с инвесторами.
+----------------------------------+
| Gameplay / Game Logic |
+----------------------------------+
| Replication / RPC |
+----------------------------------+
| Render Graph + Material | <-- слои в чистом виде
+----------------------------------+
| RHI (общий фасад над API) |
+----------------------------------+
| DirectX 12 / Vulkan / Metal |
+----------------------------------+
| Драйвер + GPU |
+----------------------------------+
^
| side utilities
| log, hash, allocator, mathВ реальной жизни эта картинка работает примерно в том же проценте случаев, в каком работает картинка из IKEA с готовой кухней и слои редко удаётся правильно применять, потому что это тяжело, дорого и долго. Платить за пирог приходится с самого первого дня, когда у вас ещё нет ни рендера, ни звука, ни геймплея, а у вас уже идёт многочасовое обсуждение того, что именно лежит в интерфейсе нижнего слоя и кто его будет кодить.
Зато если вы это обсуждение провели и убедили всех что это действительно надо, то через три года, когда нужно будет менять рендер с DirectX 11 на DirectX 12, или менять платформу с PS4 на PS5, или вообще выкидывать сетевой стек и переписывать его на месседжи от Apple, вы либо просто меняете один слой, либо ровно так же, как и раньше, переписываете пол-приложения, но уже в курсе, что вы переписываете пол-приложения, а не «ну, поправим в паре мест».
Самый частый и до сих пор живой пример слоев это любой системный API, и OpenGL слой в приложениях жив ровно потому, что он простой и ему совершенно всё равно, рендерите вы Crysis или плеер для гифок. Фасад со стороны нижнего слоя устроен так, чтобы ни один верхний в него не протекает, и все Win32, POSIX, Vulkan, DirectX, Wwise SDK, Steamworks API, PlayStation libpad, всё это слои в чистом виде, которые дают функции, типы и обещание что эти функции работают, и не дают никаких обязательств учитывать вашу архитектуру, ваш менеджер ресурсов или ваши планы по DLC.
Но когда вы пишете свой собственный слой внутри игры, главным критерием успеха становится возможность сделать так, что бы ничего сверху не протекло. Если нижний слой про вас знает хоть что-нибудь, кроме функций и типов своего фасада, у вас уже не слой, а просто два модуля, которые вы втихую поженили, но почему-то забыли об этом сказать остальной команде.
Зачем нужны слои
Технические причины именно с разбиением программы на слои обычно вторичны, а на первом месте всегда стоят организационные. Слой это способ нанять отдельную команду на конкретный кусок системы и не видеть её каждый день на митингах за одним столом с командой соседнего слоя, потому что обе команды могут работать в очень разных темпах.
Слои полезны, когда у вас есть несовместимые команды. Например есть слой, который должны писать профильные специалисты из рендер-команды, которая хорошо значет что такое GPU, шейдеры, барьеры в Vulkan и подводные камни биднинга ресурсов. И слой игрового дизайна, который должны писать геймплейщики, которые хорошо понимают, как поведёт себя комбо третьего удара, если игрок отпустил кнопку на втором кадре анимации, и эти два мира редко пересекаются на уровне людей, а на уровне кода пересекаться им вообще категорически вредно. И для них GPU будет означать Game Progression Update, или кривую прогрессии в игре.
Технические бонусы тоже есть, и их два с половиной землекопа. Первый дает возможность подменить реализацию слоя целиком, скажем, под другой графический API, под другую платформу или под автотесты с моком рендера. Второй дает хорошую локализация багов, потому что если у вас слой настоящий и фасад чистый, то баг либо «выше фасада», либо «ниже фасада», и поиск сужается в разы. Половинка бонуса осталется на скорость компиляции, потому что сам заголовок фасада маленький, а тяжёлые внутренние типы спрятаны в либу которую можно пересобирать вообще раз в неделю.
Где слои ломаются
Главная неприятность слоёв в том, что слой N видит только слой N-1, и про существование слоя N+/-2 у него нет ни малейшего понятия, и это, как ни странно, гораздо чаще оказывается проблемой, чем фичей. Классический пример из реальной разработки выглядит примерно так: у вас есть слой ассетов, который кэширует загруженные текстуры по имени файла, потому что разработчику ассет-слоя кажется очевидным, что текстуры могут попросить повторно, есть слой материалов поверх него, который тоже кэширует текстуры, но уже по уникальному GUID, потому что разработчику материалов кажется очевидным, что текстуры идентифицируются GUIDом, и есть слой рендера ещё ниже, который тоже немножко кэширует «горячие» текстуры в своих собственных структурах, потому что разработчику рендера некогда было разбираться, кэширует ли его кто-то выше.
В итоге одна и та же 4K-текстура героя живёт в памяти в трёх копиях и никто из трёх авторов не делает ничего технически неправильного с точки зрения своего слоя пока кто-то не попадёт на консоль с восемью гигабайтами общей памяти и не упрётся в OOM на стартовой локации. Но написали красиво и в рамках паттерна.
Вторая большая проблема слоёв это так называемые cross-cutting concerns, не смог подобрать внятного короткого слова по-русски. Это штуки вроде логирования, профилирования, аллокатора, телеметрии и подсистемы строк, которые по своей природе пронизывают вообще всё и любое честное «слой видит только нижний слой» применительно к ним выглядит так, что вам нужно протаскивать logger* и allocator* через все границы как параметры.
Через два месяца проект превращается в гирлянду из четырёх дополнительных параметров в каждой функции, после чего техлид неделю плюется ядом и делает за ночь глобальный g_logger реализуя в проекте очередной анти-паттерн, но зато работать становится в два раза легче и быстрее.
Игровые примеры слоев
Почти всегда сетевой стек игры поверх TCP/IP это самый чистый, какой только бывает, пример слоёного пирога, потому что в сетевом коде обмен между слоями физически идёт пакетами и эти пакеты можно поймать в том-же Wireshark, посмотреть и убедиться, что верхний слой действительно ничего не знает о нижнем кроме того, что нижний пакет переносит.
Снизу у нас IP, поверх UDP с парой стандартных полей, поверх reliable channel движка, который умеет реcендить потерянные пакеты и собирать их в правильном порядке (это, например, как сделано в Source Engine с его netchannel-ами или в Unreal NetDriver с его bunches), поверх него накручен репликация, который превращает «изменился HP вон того игрока» в «положи в пакет четыре байта по offset’у такого-то и не забудь дельту с прошлым тиком», а поверх всего этого уже игровая логика, который знает только, что у игрока есть HP, и не подозревает, что под ним четыре этажа инфраструктуры.
Quake III в своё время сделал ровно такой же стек и опубликовал его исходники, и до сих пор половина indie-шутеров вдохновляется его netcode именно потому, что разделение по слоям там очень чистое, и их можно перенести в новый движок без понимания внутренностей нижних уровней.
Графический стек это тот же сетевой пирог, только в десять раз толще. На дне у нас драйвер видеокарты, поверх него лежит DirectX 12 или Vulkan со своим API, поверх него обычно делают так называемый RHI (Render Hardware Interface, термин из Unreal, в Frostbite это называется иначе, но смысл тот же), который абстрагирует разные графические API за общим фасадом, поверх RHI лежит Render Graph (FrameGraph в Frostbite, RDG в Unreal, RenderGraph в Unity SRP), который строит граф зависимостей между проходами и сам расставляет барьеры и ресурсные переходы, а поверх него уже лежит система материалов и High-level Renderer, который уже умеет рисовать модель на сцене, и не знает про «барьер ресурса с COPY_DEST на SHADER_RESOURCE».
Каждая граница в этой башне это настоящий слой, поэтому когда Epic в один прекрасный день переименовали свой Render Graph и поменяли в нём пол-API, верхние слои это пережили, потому что работали через фасад, а не через прямой доступ к внутренностям. И наоборот, движки, в которых геймплейный код умудряется откуда-то знать про ID3D12CommandList, обычно при смене графического API переписываются целиком и сильно дольше, чем планировалось.
Звуковой стек тоже устроен очень похоже, потому что задачи у него те же. Высокоуровневые события вроде «выстрелил пистолет рядом с игроком» нужно превратить в конкретные сэмплы, прогнанные через DSP-цепочку, смикшированные с десятком других звуков и отправленные в конкретный аппаратный буфер операционной системы. Wwise, FMOD, Audiokinetic, все они под капотом раскладываются в свои внутреннние слои, у которых есть Events на самом верху, Sound Banks под ними, Mixer и Bus Routing уровнем ниже, DSP-граф ещё ниже, и WASAPI на Windows, CoreAudio на macOS, и какой-нибуль AudioRenderer на консолях на самом дне, причём ваш геймплейный код общается строго с верхним слоем, посылая PostEvent("Wpn_Pistol_Fire"), и совершенно не в курсе сколько фильтров и шин этот вызов пройдёт.
id Tech 4 (Doom 3, 2004) это, наверное, самый понятный учебный пример слоёного движка, потому что когда Кармак выложил его исходники, любой желающий смог увидеть там физическое разделение на sys, renderer, framework, idlib и game, причём game.dll собирается отдельной dll, не линкуется с рендером напрямую, не знает про OpenGL, а общается с миром через интерфейс idGameLocal и горстку колбэков. Поэтому моддеры в своё время делали полные конверсии игры, не трогая ни строки кода в движке.
Тот же Source Engine тоже выглядит слоёно на бумаге, но если посмотреть в реальный код, там очень много мест, где client.dll неожиданно много знает про материальную систему и про вертексные форматы. Моддерам он сдаётся менее охотно, ровно по той причине, что слои это договор, и стоит один раз начать его потихоньку нарушать, чтобы в итоге от слоёв остаются только файлы исходинков с осмысленными именами.
Когда слои применять не стоит
Слои очень плохо работают на трёх типах проектов, тут я опираюсь на собственные шишки. Во-первых, на маленьких проектах в одного-двух человек, где проектирование слоев съест столько времени, что игру вы напишете не в этом квартале и, возможно, не в следующем, и в этом случае «жирный» main.cpp с прямыми вызовами рендера и звука будет ровно тем правильным решением, которое позволит дойти до релиза, а потом уже горевать, что код стал нечитаемым.
Во-вторых, на проектах с очень высокой частотностью изменений требований, например, на ранних прототипах геймплея, где геймдизайнер каждую неделю меняет правила боя и любые «контракты между слоями» переписываются ровно тогда же, когда меняются правила, и слои в этом случае оказываются формой бюрократии, тормозящей итерации.
В-третьих, на любых системах, где разные «слои» физически должны общаться с одним и тем же ресурсом без копий и посредников, как, например, между симуляцией физики и анимацией скелета, между ECS-миром и AI-планировщиком, между рендером и стримингом ассетов. Там попытка спрятать одного от другого за фасадом обычно даёт либо тонну копирований, либо очень дорогие вызовы, либо и то, и другое одновременно.
Поэтому, выбирая слои как основной приём организации кода, разумно сразу задать себе три вопроса: сможете ли вы потратить заметную часть бюджета первых месяцев на проектирование интерфейсов, прежде чем увидите первый кадр игры? готовы ли вы держать в команде дисциплину «слой N плюс один ничего не знает про слой N плюс два» на горизонте как минимум года? Есть шанс получить от этой архитектуры выигрыш, который оправдает её стоимость?
Если шанс есть и команда готова, слои окупятся очень хорошо, а если нет, вы построите красивую слоёную башню, в которой геймдизайнер начнёт долбить дырки с криком «мне срочно нужен прямой доступ к рендеру», и из этих дырок постепенно вырастет унитарная архитектура, которую вы и не планировали. Больше про унитарную архитектуру есть в книге, но думаю общее впечатление можно составить и из статьи.
Metalayers (метаслои)

Если слой это договор «я общаюсь с миром только через фасад», то метаслой это уже договор «я не просто общаюсь, а формулирую тебе мои намерения на твоём же языке, и ты сам решаешь, как именно их выполнить».
Метаслой обычно стоит между настоящим слоем и pipe (каналом) потому что снаружи он выглядит как слой и пользуется тем же фасадом, но внутри устроен иначе. Теперь код больше не вызывает функции один за другим в надежде, что нижний слой их честно выполнит в том же порядке, а собирает декларативное описание того, чего он хочет добиться, отдаёт это описание метаслою целиком, а уже метаслой переводит описание в последовательность вызовов нижнего API, причём чаще всего не в ту последовательность, в которой описание было записано, а в ту, которая выгоднее по производительности, по памяти, по синхронизации или по чему угодно ещё, что метаслой умеет оптимизировать.
Особенно это видно в движках Naughty Dog и их DSL под названием GOAL (Game Oriented Assembly LISP) на ранних PlayStation, который к моменту The Last of Us превратился в более скромный, но архитектурно похожий DC (Data Compiler), и в обоих случаях геймплейный код Crash Bandicoot или Uncharted это не сишные функции, как у большинства, а Lisp-подобные выражения, которые компилируются в собственный bytecode и проигрываются виртуальной машиной поверх обычного движкового слоя.
Это, наверное, самый яркий пример того, что метаслой это не «абстракция», а полноценный второй язык программирования внутри проекта, со своим парсером, компилятором, оптимизатором и рантаймом, и держать такой зоопарк имеет смысл ровно тогда, когда вы делаете не одну игру, а серию, в которой сэкономленные на итерациях геймдизайнеров годы окупают вложения в инфраструктуру.
Что метаслой действительно даёт и чем за это придётся платить
Главный выигрыш метаслоя помимо собственно скорости и оптимизаций состоит в том, что у вас появляется отдельная система, которую можно сохранять, версионировать, диффить и валидировать ещё до того, как игра вообще запустится. И тот-же рендер-граф можно сдампить в JSON и сравнить между билдами, материал можно открыть в редакторе и увидеть, что в нём поменялось со вчера, а дерево поведения (BT) можно прогнать на сотне тестов с навигацией, и каждое из этих действий невозможно для обычного слоя, в котором между «верхом» и «низом» лежит просто вызов функции.
На больших проектах эта возможность отдельно собрать, отдельно сохранить и отдельно проверить оказывается важнее, чем все оптимизации вместе взятые, потому что именно она позволяет 30 техническим художникам и 10 AI-программерам параллельно работать над игрой и не сталкиваться по 200 раз в неделю в каких-то багах.
Расплачиваться за это тоже придётся, и расплата у метаслоя довольно характерная. У вас всегда есть некоторая задержка между «изменил декларацию» и «увидел результат», потому что между ними стоит компилятор или планировщик, так что приходится параллельно вкрячивать горячую перезагрузку метаслоя (Hot reload, Unreal Live Coding, Unity hot reload, Niagara on-the-fly compile), или жить с длинными циклами итераций и тихо ненавидеть свой пайплайн.
Отлаживать метаслой на порядок сложнее, чем обычный, потому что когда в нижнем API произошла ошибка, обратный путь до строчки в описании лежит через цепочку трансляций, и без хороших инструментов вроде RenderDoc или встроенного дебаггера для Behavior Tree расследование превращается в гадание на кофейной гуще.
Метаслои очень любят расти в сторону полноценных языков программирования, и в какой-то момент Material Editor обзаводится Custom HLSL Node, Behavior Tree получает Composite Decorator с произвольным C++ кодом, а Render Graph учится принимать «callable passes» с, опять же произвольным кодом внутри. В этот момент стоит честно сказать себе, что ваш метаслой стал DSL (Domain Specific Language), документировать его как язык и относиться к нему как к языку, а не как к набору «нод в графе».
Декларация (граф, дерево, материал)
|
v
+------------------+--------------------+
| Метаслой: |
| парсинг -> валидация -> анализ -> | <-- здесь живёт компилятор
| планирование -> кодогенерация | и/или планировщик
+------------------+--------------------+
|
v
Команды нижнего слоя
(DX12/Vulkan, HLSL,
compute-программа, AI Task)Если попытаться сформулировать практическое правило, оно звучит так, что метаслой стоит делать тогда, когда у вас на проекте есть отдельная группа людей, которая будет ежедневно им заниматься, и при этом нижний слой достаточно сложен, чтобы наивная генерация вызовов в каждой такой правке давала плохой результат либо по производительности, либо по корректности, либо по объёму ручной работы, либо по чему-то еще.
Если первое условие не выполнено, метаслой будет писать и поддерживать ровно тот же человек, который и так писал бы прямые вызовы, и метаслой ему окажется только обузой. Если не выполнено второе, ваш метаслой будет очень дорогим способом запихнуть простой код в граф, потому что это красиво и можно показать на конференции.
Зато если выполнены оба условия, а такие проекты в современной индустрии скорее правило, чем исключение, метаслой довольно быстро становится тем местом, где сосредоточены и главные оптимизации движка, и главная продуктивность контентной команды одновременно, и тимлид, который смог такой метаслой продать, сделать и выкатить, обычно может смело идти просить себе следующую ачиву в профиль, и может убитых енотов побольше.
Subsystems (подсистемы)
Если слои редко удаются сделать хорошо, или они полчауются тяжелые, то подсистемы можно сказать противоположное. Это типичный выбор, когда вы заранее готовы разменять чистоту ради меньших рисков, и так устроено абсолютное большинство реально живущих сегодня движков, от мала до велика.
Подсистема внешне очень похожа на слой, у неё тоже есть свой собственный код, свой условный фасад и своя ответственность, но если у слоёв на доске рисуется стройная вертикальная башня с ровно нарисованными границами, то у подсистем на той же доске получается кучка кружочков со стрелочками, и стрелочки эти летят в разные стороны, потому что рендер хочет знать у физики, какие объекты сейчас живы, физика хочет знать у анимации, какие кости куда переехали, а анимация хочет знать у AI, в какую сторону идёт боец. AI не должен, но очень хочет узнать у рендера, видит ли камера сейчас вон того монстра, чтобы решить, стоит ли вообще запускать дорогую логику проверки видимости рейкастами.
Главное различие между слоем и подсистемой состоит не в том, что одни лучше других, а в том, что границы подсистем заранее признаются всему участниками протекающими. Слой это договор «ты меня не видишь, я тебя не вижу, общаемся через фасад», а подсистема это договор «мы вынуждены общаться часто, по самым разным поводам и в обе стороны, давайте хотя бы договоримся, кто кого как зовёт и какие объекты между нами летают».
Поэтому, когда вы делаете слой, у вас уходит две недели на проектирование фасада, и потом два года вы за этот фасад защищаете, чтобы через него никто лишний не лазил. Когда вы делаете подсистему, у вас уходит два дня на её первый интерфейс, и потом два года вы подкручиваете этот интерфейс по мере того, как геймдизайнер придумывает новые поводы вашему AI общаться с вашим звуком, и это часть платы за более низкие риски на старте.
Зачем вообще подсистемы, если слои чище?
Потому что в реальной игре между потенциальными «слоями» идёт такая плотная коммуникация, что упрятать её за вертикальной иерархией с фасадами либо физически невозможно, либо настолько дорого, что игра до релиза не доедет. Когда рендер каждый кадр должен пройтись по сцене, спросить у физики bounding box каждого динамического объекта, у анимации пощупать матрицы костей для скиннинга, а еще у LOD-менеджера выяснить, какой меш сейчас активен и сделать всё это за полтора миллисекунды, чтобы успеть отдать кадр в GPU, любая попытка спрятать соседа за тяжёлый фасад с виртуальными вызовами и преобразованиями типов превращает 1.5 мс в 4.5, а половину кадра вы тратите на сам факт того, что красиво организовали свой код.
Поэтому подсистемы появляются не из любви к чистоте, а из признания того, что в игре есть набор крупных модулей, каждый из которых живёт своей жизнью, сопровождается отдельной командой, профилируется отдельно, имеет собственные структуры данных и собственные планы оптимизации, но при этом они вынуждены постоянно обмениваться информацией о большом и общем мире.
Единственный осмысленный способ это организовать состоит в том, чтобы выделить эти модули в подсистемы и разрешить им знать друг о друге, сосредоточив усилия не на изоляции, а на минимизации количества и стоимости их взаимодействий.
Что обычно подсистемой называют, а что нет
На бумаге подсистемой принято называть достаточно крупный модуль, который владеет каким-то существенным куском состояния игры (рендер владеет сценой, физика владеет физическим миром, AI владеет своим планировщиком и blackboard). А еще имеет свой регулярный обновляющий цикл, который вызывается из главного game loop по тику или по фиксированному шагу, и имеет внешнее API, которое более-менее независимо от внутренних структур данных.
Если у вас есть код, который не владеет собственным состоянием и не имеет регулярного тика, это, скорее всего, не подсистема, а либо утилитарная библиотечка, либо набор хелперов, и его правильно вынести тулы и не размахивать им как архитектурным достижением.
Типичный набор подсистем в современном движке выглядит примерно так: Renderer, Physics, Animation, AI, Audio, Streaming, ResourceManager, Input, Network, UI, и поверх всего этого World/Scene, который, как правило, не подсистема в чистом виде, а скорее общий объект, который все эти подсистемы знают и к которому они умеют обращаться.
Между подсистемами летают сущности игрового мира, чаще всего в виде указателей или хэндлов, и в зависимости от стиля движка это либо GameObject с компонентами в духе Unity, либо Actor с компонентами в духе Unreal, либо Entity без поведения, но с набором тэгов и компонентов в духе ECS, и архитектурный выбор этого «общего объекта» во многом и определяет, насколько комфортной получится жизнь подсистем рядом друг с другом.
Source Engine как образцовый пример подсистем
Source Engine у Valve это, наверное, наиболее академичный пример движка, организованного именно как набор подсистем, потому что Valve физически разнесли эти подсистемы по отдельным DLL, и любая подсистема в Source это IFooSystem*, получаемый через специальный механизм фабрики при загрузке движка.
Конкретно у них есть studiorender.dll для рендера моделей с интерфейсом IStudioRender, vphysics.dll для физики с интерфейсом IPhysics, soundemittersystem.dll для абстракции игровых звуков поверх IEngineSound, materialsystem.dll для материалов, vgui2.dll для интерфейса, и десяток других, и каждая dll при загрузке получает функцию CreateInterface(const char* name, int* return_code), через которую engine.dll запрашивает у неё реализации, а сама dll через ту же функцию запрашивает у движка реализации других подсистем.
Эта схема прекрасна тем, что в один прекрасный день Valve смогли перейти от движка Half-Life 1 GoldSrc к Source без переписывания всей игры, а в другой прекрасный день они смогли подменить физику с собственной самописной на Havok-овский vphysics, и в третий прекрасный день они смогли добавить новые типы рендера, не трогая интерфейс старого, и моддерское комьюнити получило возможность писать собственные mod-DLL-ки.
Заплачено за это было известной ценой и те, кто хоть раз смотрел в исходники SDK, знают, что вызов даже одной простой функции в соседней подсистеме это виртуальный вызов через интерфейс, что суммарно по кадру дают не самые маленькие накладные расходы.
CryEngine и большие окна в подсистемы
CryEngine пошёл по похожему пути, но взял другую крайность вместо горстки небольших dll с маленькими интерфейсами, Crytek сделали меньше dll, но каждая получила огромный, плоский интерфейс с десятками методов наружу. I3DEngine это собственно весь рендер плюс сцена плюс растительность плюс океан плюс terrain, IPhysicalWorld это вся физика с её Vehicle, Particles, Cloth и Ropes, ICharacterManager это вся анимация и скелетный механизм, IAISystem это весь AI, и так далее, и так далее, и любой код в игре, которому нужно сделать что-то с любой из этих подсистем, делает gEnv->p3DEngine->FuncName(...), что мгновенно превращает gEnv в god object, через который из любой точки кода можно дотянуться до чего угодно, и Crytek честно к этому признаются об этом в документации.
Это решение тоже имеет под собой логику, потому что оно очень сильно снижает порог входа для новичка, который пришёл из mod-сцены или из инди и хочет «просто заставить вон тот объект светиться зелёным», и не хочет разбираться в фабриках, factory factory и dependency injection.
Зато его же ценой становится то, что внутри I3DEngine за пятнадцать лет накопилось столько методов, что между ними появились скрытые связи, не выраженные в интерфейсе, и любой, кто пытался что-то делать на CryEngine, знает что если вы трогаете один параметр в функции, то через какое-то время месяца обнаруживается, что вы поменяли поведение растительности в тумане на закате, потому что эти куски кода неявно делили глобальный кэш.
Unreal и пакетная мутация: подсистемы как явная сущность движка
Unreal Engine долгое время жил с очень специфической моделью, в которой роль подсистем играли UEngine, UWorld, UGameInstance и UGameViewportClient, то есть несколько больших объектов, к которым все остальные тянулись через статические геттеры или через указатель на мир, и это работало, но плохо масштабировалось при добавлении нового кода.
Поэтому начиная с UE 4.22 Epic добавили в движок явный механизм USubsystem, у которого есть несколько подвидов: UEngineSubsystem живёт всё время существования процесса, UGameInstanceSubsystem живёт пока запущена игровая сессия, UWorldSubsystem живёт пока существует мир, а ULocalPlayerSubsystem живёт пока существует локальный игрок, и достаются они одинаковым шаблонным вызовом GetSubsystem() от соответствующего владельца. Это очень показательное решение, потому что Epic фактически взяли узнаваемый паттерн «подсистема как живущий своей жизнью объект с заявленным временем жизни» и встроили его в кодогенерацию, в reflection и в редактор, после чего любой разработчик геймплейного кода смог объявлять собственные подсистемы парой строк, не разбираясь в потрохах движка, а движок взамен получил возможность управлять их временем жизни автоматически.
Под капотом это всё ещё куча окружностей со стрелочками, потому что подсистемы Unreal видят друг друга через те же GetSubsystem лукапы, но снаружи появилась хоть какая-то дисциплина, и это для индустрии редкое и приятное явление, когда крупная компания делает что-то не для красивого слайда на GDC, а для уменьшения количества выстрелов в ногу у разработчиков геймплея.
Unity, Bevy, DOTS: подсистема как «система» в ECS
ECS-движки последнего поколения почти буквально подняли понятие подсистемы в основной архитектурный примитив, и в Unity DOTS, в Bevy и в EnTT-based движках под подсистемой понимается ровно то, что и называется в коде словом System, то есть кусок кода, который в каждом тике пробегает по компонентам определённого набора типов и что-то с ними делает.
Между ECS-системами объекты не циркулируют как самостоятельные сущности с поведением, а лежат в общем World, и каждая система получает к ним доступ в результате паттерн получается тот же самый, что и у Source с CryEngine, только переехавший на другой уровень абстракции, более похожий на работу с базой данных.
С точки зрения архитектуры здесь интересно, что границы подсистем стали ещё более явными за счёт того, что доступ к данным проходит через единый менеджер мира, и при этом вся та же логика про «они знают друг о друге» сохраняется, потому что система движения хочет данные из системы навигации, система рендера хочет результаты системы анимации, и так далее. Просто вместо IFooSystem* или gEnv->pFooBar в коде стоит запрос компонентов вида Query<&Position, &Velocity>, а вместо ручной вызовы методов соседей идёт неявная синхронизация через диспетчер, который сам решает, в каком порядке запускать системы и какие из них можно гонять параллельно.
Как подсистемы обычно общаются
Способов общения у подсистем не очень много, и каждый из них тоже имеет свою цену. Первый и самый очевидный это прямой вызов через интерфейс соседа, и им пользуются абсолютно все, потому что он самый понятный и легко отлаживается.
Расплатой за это становятся жёсткие связи и если подсистема A зовёт подсистему B напрямую, то выкинуть B или подменить её на что-то другое уже больно. Второй это общий объект игрового мира, через который подсистемы обмениваются состоянием, не зовя друг друга, а просто читая и записывая данные сущностей.
Теперь рендер не зовёт анимацию, а читает у Entity её матрицы скиннинга, анимация не зовёт физику, а читает позицию из трансформа, и так далее, и это снижает количество прямых вызовов, но взамен резко повышает сложность модели когерентности данных, потому что теперь нужно отвечать на вопрос «когда чьи данные актуальны, кто чьи перетер и в каком тике это произошло».
Третий способ это сообщения и события, начиная от простых сигналов в духе Qt/UE Multicast Delegate, и заканчивая полноценным шинами событий с очередью и приоритетами, и они хорошо работают для редких асинхронных взаимодействий типа «игрок умер, всем разослать оповещение», но плохо работают для частых регулярных тиков, потому что добавляют задержку в один кадр и теряют локальность.
Четвёртый и самый коварный это Service Locator, то есть глобальный реестр сервисов, из которого любой код может получить любую подсистему по имени или по типу, и Service Locator удобен на старте проекта, но имеет очень плохую привычку превращаться в очередной god object с парой сотен зарегистрированных «сервисов», и в этот момент вы тихо переоткрываете для себя ровно тот gEnv из CryEngine, только в виде, который вы вырастили сами и про который раньше думали, что у вас уж точно так не получится.
Где подсистемы ломаются
Главных проблемы три, но каждая из них приходит со временем. Первая это циклические зависимости, потому что подсистемы по определению знают друг о друге, и через год оказывается, что AI читает из рендера видимость камеры, а рендер читает из AI приоритеты для LOD-выбора, и при попытке вынести любую из этих двух подсистем в отдельную dll вы получаете циклический include или линкер, ругающийся на циклические зависимости символов.
На бумаге всё это решается выделением общих интерфейсов в отдельный модуль или введением посредника, а на практике это болезненный многонедельный рефакторинг, который никогда не получает приоритета, потому что текущая архитектура «вроде работает».
Вторая проблема это рост числа god object-ов, который можно увидеть и у Crytek с их gEnv, и в куче самописных движков, в которых появляется класс Game или World или Engine, который владеет всеми подсистемами, и через него любой код может дотянуться до любой подсистемы. Поначалу это очень удобно, пока не возникает вопрос, как протестировать одну подсистему без двадцати соседей, или как распараллелить обновление между ядрами, не получив гонок данных.
Третья проблема это накладные расходы, причём не столько на сам виртуальный вызов, сколько на пересечение границы кэша процессора и на работу с указателями. Когда подсистема A вызывает в подсистеме B функцию, которая внутри ходит за данными в подсистему C, мы получаем кэш-мисс по адресу B, потом ещё один по адресу C, потом ещё один по данным, на которые C указывает, и при достаточном количестве таких пересечений за кадр получается офигенный бюджет кадра, незаметно растёкшийся по системам.
Когда выбирать подсистемы как основной приём организации
На горизонте больше одного-двух человек подсистемы это разумное решение, поскольку они дают разделение труда и относительно понятную модульность без людских затрат, которые требуются для строгих слоёв, и большинство движков, на которых вы видели рабочие игры, организованы именно так.
И если слои разумно применять там, где у вас уже есть стабильный, не очень часто меняющийся фундамент, например, ваш собственный HAL поверх платформенных API или собственный фасад поверх Vulkan, и где вы сознательно готовы заплатить дополнительной дисциплиной за возможность когда-нибудь подменить реализацию. То подсистемы стоит выбирать тогда, когда у вас есть несколько крупных модулей с собственным состоянием и собственным циклом обновления, между ними неизбежно частое взаимодействие, и команда готова с самого начала договариваться об интерфейсах не как о неприкосновенных контрактах, а как о живом документе, который раз в спринт уточняется по результатам встреч.
Параллельно с этим стоит сразу решить вопросы какой у вас будет общий объект игровой сущности? как именно подсистема находит соседа (фабрика, локатор, прямой указатель из конструктора, регистрация в реестре)? И кто и в каком порядке вызывает их тики?
Эти три вопроса определяют половину архитектуры движка, и избавляют команду от очень многих неприятностей дальше. Или потом превращаются в те самые многолетние рефакторинги, в которые я никому из коллег не желаю попадать.
+----------------+
| World |
| (game entities,|
| general state)|
+-------+--------+
|
+----------+------------+------------+----------+
| | | | |
+---v---+ +---v----+ +----v----+ +----v----+ +---v---+
|Render | |Physics | |Animation| | AI | | Audio |
+---+---+ +---+----+ +----+----+ +----+----+ +---+---+
^ ^ ^ ^ ^
| | | | |
+----------+------+-----+------+-----+---------+
| |
Streaming ResourceManager
стрелки идут в обе стороны
подсистемы видят друг друга
общий объект мира хранит сущности, которые они совместно
читают и пишут
utilities (log/hash/allocator/math) как всегда висит сбоку и доступен всемГлавное, что стоит держать в голове после всего этого разбора. Подсистемы это не маленькие, аккуратно изолированные кирпичики, как иногда пытаются изобразить на лекциях, а крупные, более-менее независимые блоки, у каждого из которых свои интересы и своё состояние, и архитектура подсистем это отдельная попытка организовать их совместную жизнь так, чтобы они приносили друг другу больше пользы, чем проблем.
Если у вас это получилось, у вас на доске стоит та самая «куча окружностей со стрелочками», и она ровно тем и хороша, что отражает реальную физику игрового кода, в которой данные текут не сверху вниз по красивой башне, а во все стороны сразу, и архитектор, который это принял, получает в итоге работающий движок, а архитектор, который продолжает рисовать вертикали, обычно получает либо красивую слайдовую презентацию, либо очень дорогостоящий рефакторинг на третий разработки.
Pipes & Filters (Конвейер)

Если слой это большет про фасад и нижние уровни системы, а метаслой это про описания и структуры, то конвейер и фильтры это уже механика: «я просто беру данные на входе, что-то с ними делаю и выкладываю результат на выход, а кто их дальше подберёт и куда понесёт не мои проблемы».
Каждый фильтр здесь это маленькая утилита/код/логика с понятной сигнатурой «принимает вот это, отдаёт вот то». Такие утилиты можно собирать в цепочку или в граф, и поверх них обычно стоит какой-нибудь оркестратор вроде nmake, Jam, Bazel, build-фермы или внутреннего таск-раннера движка, который и решает, в каком порядке запускать конкретные звенья.
В идеале получаем набор маленьких комбинируемых утилит, которые «своей гибкостью и комбинируемостью решат любую задачу», потому что у конвейера есть совершенно отдельное от слоёв и подсистем свойство, когда его можно подёргать руками, переупорядочить, вставить в середину новый фильтр и понять, что получилось, не пересобирая всю игру.
Конвейер обычно используется как механизм сборки и обработки данных, чувствуя себя тут прекрасно, потому что данные у нас обычно ходят в одну сторону, и переменное состояние между фильтрами либо отсутствует вовсе, либо для него пишется еще один маленький фильтр, который его обрабатывает.
Еще конвейер пытаются использовать как средство обработки игровых сообщений или как полноценную архитектуру для рантайма, но я почти не видел случаев, когда из pipes/filters в получалась бы нормальная архитектура. Причина у этого сугубо практическая, потому что рантайм не любит, когда у него «всё развалилось, давайте откатываться», и ему нужно просто дорисовать кадр, а у конвейера никакой нормальной обработки ошибок по устройству не предусмотрено и фильтр, увидевший сломанные данные, либо пробросит их дальше с ошибкой, либо отдаст наружу вообще что-нибудь странное, и любой из этих вариантов в рантайме означает либо упавший кадр, либо невидимого героя, либо странный эффект.
Офлайн-сборка контента
Самое частое и до сих пор живое место конвейеров в играх это сборка ассетов вида create_normal_map(highpoly.max, lowpoly.max, 666, …), которая интерпретируется как смесь инструкция «возьми вот эти два файла, прогони через утилиту bake_normal_map и положи результат с таким-то именем.
В современном виде эта же мысль развернулась в полноценные системы вроде UnrealBuildTool + AutomationTool в Unreal, AssetImporter + AssetPostprocessor + Addressables Build Pipeline в Unity, Frostbite Build Farm у EA Dice, Houdini Engine + PDG (Procedural Dependency Graph) для процедурной генерации, и собственных самописных систем у Naughty Dog, Rockstar, Guerrilla и всех остальных, у кого хватает ресурсов сделать свою.
Архитектурно эти системы устроены как набор маленьких импортёров и сборщиков, каждый из которых умеет конвертировать один тип входа в один или несколько типов выхода, есть граф зависимостей между ассетами, который строится из метаданных и из объявлений самих импортёров, и есть планировщик, который смотрит на даты модификации входов и решает, какие звенья пересчитывать, а какие можно достать из кэша, локального или сетевого.
Когда художник в Maya сохраняет Hero_LOD0.fbx, в системе срабатывает цепочка вида «fbx parser → mesh extractor → tangent generator → vertex format packer → cooked .uasset», параллельно с ней «fbx parser → skeleton extractor → animation clip → compressor → cooked skeletal animation», и таких параллельных цепочек на одну исходную модель может быть десяток, и весь этот зоопарк обрабатывает либо локальная сборка разработчика, либо ферма, на которой ночью пересчитывается всё, что изменилось за день.
Конвейеры почти везде используются для сборки шейдеров, потому что один и тот же исходный материал в Unreal или Unity порождает на выходе десятки тысяч вариантов шейдера, каждый из которых это отдельный артефакт со своими зависимостями от платформы, от качества, от feature level, от настроек текстур.
Если бы это всё считалось монолитом, любая правка тривиального материала пересчитывала бы всё с нуля несколько часов, но благодаря тому, что сборка шейдеров сделана как конвейер с зависимостями и кэшем, реальная пересборка после правки одного параметра занимает секунды на разработчике и минуты на CI.
Расплачиваться за этим приходится разбором ошибок, когда что-то ломается в середине конвейера. Тогда разработчик получает невнятный лог в духе «cook failed for asset X at stage Y», и дальше ему предстоит самостоятельно понять, какой именно фильтр и почему не сработал, потому что у конвейера нет никакого нормального стека вызовов, у него есть только цепочка из десяти утилит, каждая из которых что-то логирует своим способом.
В крупной студии обычно существует отдельный человек, чья работа состоит в том, чтобы эту самую отладочную инфраструктуру вокруг конвейера поддерживать в живом состоянии, потому что без неё художники приходят к программистам каждые тридцать минут с вопросом «у меня н...я снова не собирается».
DSP-графы в звуке
С аудио история ровно та же, что и со сборкой ассетов, и здесь у конвейера получилось то, что почти ни в каком другом месте получиться не может, а именно полноценная жизнь в рантайме на десятках миллионов устройств. FMOD Studio, Wwise, Unreal MetaSounds, Unity Audio Mixer все они построены вокруг DSP-графа, в котором источник звука это самый первый фильтр (sampler из памяти, либо стриминговый декодер, либо генератор синтезатора), затем идут поканальные DSP-эффекты типа low-pass, high-pass, distortion, reverb, occlusion, pitch shift, send/return шины, потом мастер-шина, и в самом конце аппаратный вывод.
Каждое звено это свой маленький фильтр с понятным входом и выходом из массива сэмплов, оркестратор это аудио-движок, который раз в N миллисекунд собирает новый блок и протаскивает его через граф. Конвейер тут получился в рантайме именно потому, что у аудио, в отличие от рендера и геймплея, есть две особенности. Во-первых, поток данных гарантированно идёт в одну сторону, и обратной связи между «куда писать» и «откуда читать» практически нет, потому что нельзя послушать звук, который ещё не сыграл. Во-вторых, ошибка в одном фильтре в худшем случае означает «эта дорожка зазвучит криво или замолчит», и игроку это, конечно, неприятно, но это не сломаный кадр и не невидимый персонаж, поэтому конвейер с его слабой обработкой ошибок здесь живёт спокойно. И в-третьих, звукорежиссёру категорически нужна возможность взять и переставить местами или добавить эффект в любую точку графа, потому что весь смысл его профессии в этом, и аудиосистема, которая такое не позволяет, у звукачей вызывает живую ненависть и быстро заменяется на ту, которая это делать позволяет.
Post-process stack и рендер-конвейер
В графике конвейер живёт в виде post-process цепочек, и это, наверное, самое визуально знакомое место для разработчика-новичка. Unity URP/HDRP называет это Volume Profile + Post Process Stack, Unreal называет это Post Process Volume, Godot имеет собственные WorldEnvironment, у Frostbite и id Tech собственные внутренние названия, но архитектурно везде одно и то же.
После того, как рендер собрал сцену, начинается цепочка фильтров вида «motion vectors → TAA или DLSS или FSR → SSAO/GTAO → SSR → bloom → depth of field → exposure → tonemap → color grading → film grain → vignette → final blit», в которой каждый фильтр это отдельный вычислительный или экранный шейдер, потребляющий один или несколько таргетов и пишущий в другой, и тот, кто настраивает сцену, может включать, выключать и переупорядочивать эти фильтры через интерфейс, не трогая код движка.
Работает в рантайме оно тоже потому, что соответствует тем же критериям, что и аудио. Данные идут в одну сторону, ошибка означает «кадр посчитался криво, но кадр посчитался», и художник по графике должен иметь возможность тыкать в эти эффекты руками, не зовя программиста.
Когда вы видите в релизе «вау! Новый трейлер, в нём добавили крутой эффект пыли в воздухе», то скорее всего, никто из программистов в этот месяц специально под этот эффект ничего не делал, просто рендер-дизайнер наконец-то включил fog volume + dust particles + bloom или перетащил пару фильтров местами и записал результат.
Очень часто конвейер встраивают в метаслой, и Render Graph в Unreal или FrameGraph в Frostbite это метаслой, который принимает декларацию кадра и перестраивает её в оптимальную последовательность для конвейера. Т.е. формирует оптимальную последовательность из фильтров и проходов с зависимостями, и получается что конвейер редко живёт сам по себе и чаще спрятан под одним или двумя метаслоями, которые отвечают за декларативный вход, а конвейер уже отвечает за исполнение. Это правильное распределение ответственности, потому что декларативный вход даёт понятную модель авторам контента, а конвейер даёт понятную модель инженерам.
Где конвейер ломается
Главная слабость конвейера, это обработка ошибок, и она проявляется сразу с нескольких сторон. Это и разрыв контекста, когда фильтр в середине цепочки получает «сломанные данные» и физически не знает, кто и где их сломал и что с этим делать, и всё, что он может сообщить, это «вход не валиден», но «вход не валиден» в логе билд-фермы для художника означает примерно столько же, сколько фраза «ничего не компилится, совсем ничего» для программиста.
Еще это отсутствие транзакций и если конвейер собирал уровень 4 часа и упал на 18-м из 20 этапов, у вас на диске лежит набор частично сгенерированных файлов, которые формально существуют, но смыслово невалидны и 4 часа можно выбросить в мусорку.
И наконец это отладка длинных цепочек. Чем больше у вас фильтров между входом и выходом, тем сложнее понять, на каком именно этапе данные начали отличаться от ожидаемых, и любая серьёзная build-инфраструктура в какой-то момент обзаводится отдельной системой для трассировки артефактов через все стадии с дампом промежуточных результатов на диск, и эта система обычно живёт вместе с отдельной командой и оплачивается отдельно.
Отдельной особенностью являются состояния, которые не помещается в фильтр. Конвейер прекрасно справляется с задачами, где каждое звено это чистая функция от входа, но как только в системе появляется глобальное состояние, которое разные фильтры должны читать или писать (например общий аллокатор, общий отсчет времени, общий лог, общий хэш дедупликации), конвейер либо начинает таскать это состояние через все фильтры явным параметром. Либо приходится заводить глобальные переменные, и в обоих случаях красивая модель «независимых звеньев» превращается в «звенья, которые знают друг о друге через черный ящик».
Когда брать pipes/filters
Архитектурно конвейер хорош ровно тогда, когда у вас на руках есть задача с однонаправленным потоком данных, в которой звенья можно описать в виде «вход типа A, выход типа B», и при этом ошибка в середине это либо «остановиться и пожаловаться человеку», либо «выбросить результат и попробовать ещё раз», но никак не «нам надо как-то аккуратно дальше работать».
Если эти условия выполнены, конвейер даёт лучшее в индустрии разделение труда между авторами разных этапов, прекрасно встраивается в кэширование, распределённую сборку, и легко расширяется на новые типы входов и выходов. Именно поэтому он живёт там, где живёт, то есть в сборке ассетов, в запекании уровней, в офлайн-обработке текстур, и нерилтаймовых пайплайнах вроде Houdini и Substance.
Если же условия не выполнены, и у вас есть обратные связи, зависимость состояния или требование «после ошибки продолжать жить», конвейер либо начнёт обрастать костылями вроде шин для контекста, либо превратится в подсистему с конвейероподобным API. Но это уже будет не pipes/filters, а что-то другое, и относиться к нему надо как к чему-то другому и возможно, нужны метаслой или подсистема, и попытка натянуть pipes/filters на ту задачу всё равно закончится тем, что придется все выкинуть и написать заново.
.fbx .png .max .wav .ttf
| | | | |
v v v v v
+------+ +-----+ +-----+ +-----+
|fbx | |png | |max | |wav | <-- импортёры,
|->mesh| |->tex| |->geo| |->snd| каждый сам по себе
+--+---+ +--+--+ +--+--+ +--+--+
| | | |
v v v v
+-------------+ +-------------+
| tangent gen | | compression |
+------+------+ +------+------+
| |
v v
+----------------+ +-------------+
| vertex packer | | texture |
| + LOD chain | | block comp |
+-------+--------+ +------+------+
| |
+--------+----------+
v
+-------------+
| cooker | <-- финальный сборщик
| .uasset/ | артефактов уровня
| .pak/.iostore|
+------+------+
v
runtime buildЕсли попытаться сформулировать практическое правило применения, то конвейер из фильтров это самая дешёвая по ментальной нагрузке архитектурная конструкция, которая существует, и одновременно самая опасная при попытке выйти за пределы её удобной зоны.
В удобной зоне, то есть в сборке ассетов, офлайн-обработке, аудио-DSP и post-process цепочках, она работает почти бесплатно, и любой движок, который пытается обойтись здесь без конвейера, выглядит странно и работает медленно.
За пределами удобной зоны, то есть в обработке игровых сообщений, в рантайм-логике геймплея и в боевых системах, попытки построить конвейер из фильтров заканчиваются примерно одинаково - переползанием обратно в подсистемы, и единственная причина, по которой я повторяю это спустя двадцать лет, состоит в том, что свежий разработчик с новой блестящей идеей «давайте всю игру сделаем как Reactive Stream» появляется в индустрии каждый год частотой примерно раз в пару месяцев.
Microkernel (Микроядро)

Это, пожалуй, самый часто неправильно понимаемый из "больших" паттернов, потому что в учебниках его обычно рисуют как «маленькое аккуратное ядро в центре и множество чистеньких модулей вокруг», и от этой картинки у читателя возникает приятная иллюзия, что мирокядро это про красоту и про правильность, а не про что-то прикладное.
На самом деле эта картинка скрывает гораздо более интересную мысль о том, что микроядро сродни договору «я не знаю и не хочу знать, какие конкретно куски функциональности у меня будут на этом проекте». Учебники описывают микроядро как способ построения расширяемой системы за счёт разделения набора простых механизмов построения и использования интерфейсов.
Плагины в большинстве движков это почти правильное применения это паттерна. Если попытаться отличить микроядро от слоёв и подсистем, то у нас получится, что у слоёв вертикальная башня, у подсистем горизонтальная россыпь окружностей со стрелочками, а у микроядра в центре стоит большой кружочек, вокруг него радиально торчат кружки расширений поменьше, и в каждый кружок расширения может прийти один или несколько плагинов.
Причём, плагины не обязаны знать друг про друга, ядро не обязано знать заранее, что именно к нему подключат, а сам набор плагинов может различаться от запуска к запуску. Это совсем другой уровень гибкости, чем у слоёв и подсистем, потому что слои защищают вас от изменений в реализации, подсистемы защищают от роста численности команды, а микроядро защищает от того, что вы заранее не знаете полного списка фич, но расчитываете, что часть из них появится после релиза, придёт от моддеров или будет включаться по подписке.
Чем microkernel отличается от просто dll и просто плагина
Любая система, в которой есть динамическая загрузка библиотек, имеет шанс быть названной «плагинной», но это ещё не microkernel, и здесь надо помнить четыре отличительных признака.
Во-первых, ядро объявляет минимальный, стабильный и версионированный интерфейс точек расширения, который не меняется без серьёзных причин, потому что любое его изменение ломает все существующие плагины разом.
Во-вторых, ядро ничего не знает о конкретных плагинах, и в особенности не зашивает их имена и типы в свой код, а получает информацию о них через стандартный механизм обнаружения, обычно это сканирование папки на диске, чтение манифеста, регистрация в реестре или вызов экспортированной функции типа CreateInterface.
В-третьих, плагины обязаны умирать без последствий для ядра, то есть выгрузка плагина или его падение не должны разрушать основной поток исполнения, и для этого ядро держит вокруг плагинов изоляционные механизмы, начиная от просто try/catch и обнуления указателей и заканчивая sandbox-процессами с IPC.
И наконец, плагины не зависят друг от друга и любая их коммуникация идёт через ядро или через стандартные сервисы ядра, потому что иначе сама идея «маленькое ядро плюс независимые расширения» в первый же релиз превращается в обычные подсистемы с глобальным реестром.
Эти четыре признака редко выполняются все сразу, и реальные игровые движки обычно дают вам три с половиной из четырёх, потому что почти все, кто заявляет у себя microkernel, на практике хотя бы в одном из четырёх пунктов жульничают, чаще всего в первом, разрешая себе ломать ABI между мажорными версиями, или в четвёртом, разрешая плагинам ходить друг к другу напрямую через приведения типов и имена.
Где microkernel живёт в реальных играх
Самый понятный игровой шаблон микроядра это виртуальная файловая система игры поверх pak-файлов, и тут этот паттерн живёт ровно так, как описано в учебнике. Ядро VFS объявляет очень узкий интерфейс типа IFileSystem с парой методов Open(path) -> IFile*, Exists(path), Mount(point, provider), и под этим интерфейсом могут регистрироваться разные провайдеры.
Один работает с реальной файловой системой ОС, другой умеет читать из .pak (как у Quake), третий из .vpk (Valve, Source Engine), четвёртый из .pak-чанков iostore (Unreal Engine 5), пятый из .bsa и .ba2 (Bethesda), шестой из .npk (Quake), седьмой из памяти, восьмой из сетевого хранилища, и так далее. С точки зрения геймплейного кода всё это одно и то же Open("textures/hero_d.tga"), а с точки зрения VFS это поиск в реестре провайдеров и делегирование запроса в нужный, и игрок никогда не ломает ноги о тысячи мелких файлов, лежащих внутри одного большого архива, потому что архив для него прозрачен.
Эта схема оказалась настолько удобна, что прижилась в большинстве движков, начиная с Quake в 1996 году, который придумал .pak, потому в Half-Life в 1998, который сделал.wad/.pak, и расцвела в Source Engine, который довёл идею до .vpk с разбиением на чанки и возможностью наложить друг на друга несколько архивов с приоритетами и патчами.
В каждом случае ядром выступает менеджер VFS, плагинами выступают модули-провайдеры под конкретные форматы, и при этом моддерское комьюнити получает огромный бонус в виде добавления новых форматов архивов, которые не требуют править движок.
Unreal как двойной microkernel
Unreal Engine это отдельный интересный случай, потому что у него мироядро есть сразу на двух уровнях, но отвечают они за разные вещи. Нижне ядро, назовем его так, это модульная система на базе IModuleInterface и .Build.cs, в которой весь движок разрезан на сотни модулей (Core, CoreUObject, Engine, RenderCore, RHI, Renderer, SlateCore, Slate, UMG, OnlineSubsystem, и десятки других). Каждый из модулей нижнего ядра компилируется как отдельный артефакт и подключается к ядру через единый механизм.
Когда вы пишете в
PublicDependencyModuleNames.AddRange(new string[]{ “Core”, “Engine”, “UMG” }),
вы участвуете в системе обнаружения и поднятия модулей, которая видит каждый модуль через его экспортированную функцию IMPLEMENT_MODULE, и поднимает их в нужном порядке, посылает событиям StartupModule и ShutdownModule, поддерживая горячую перезагрузку, благодаря которой Live Coding в UE4/UE5 вообще возможен.
Верхнее ядро это уже плагины через .uplugin, и они уже устроены ровно так, как описано в учебнике. Плагин это набор модулей с собственным манифестом, который описывает, какие фичи плагин даёт, какие модули у него внутри, на каких платформах он работает, обязателен ли он или опционален, в каких фазах загрузки движка он должен подняться (PreDefault, Default, PostEngineInit, PostDefault), и есть ли у него зависимости от других плагинов.
Когда Epic выпускают Lumen, Nanite, MetaHumans, Niagara, Chaos Vehicles, Online Subsystem Steam, всё это технически плагины, не часть движка, и их можно отключить целиком одной галочкой в .uproject, после чего движок просто соберётся без них и без их функциональности.
Этим же механизмом пользуются и сторонние разработчики, выкладывая на Unreal Marketplace всё, от моделей и материалов до полноценных подсистем рендера и нейронных сеток для AI, и это работает потому, что верхний и нижний ядра отделены друг от друга и каждое отвечает за свой уровень изоляции.
Unity, Bevy, Godot и разные степени microkernel-радикализма
Unity несколько лет назад приняли стратегическое решение разделить свой когда-то монолитный движок на пакеты через Unity Package Manager, и это, по сути стало началом движения в сторону микроядра со стороны движка, который изначально таким не был.
Сегодня в Unity почти все крупные подсистемы поставляются как пакеты: URP, HDRP, TextMesh Pro, Cinemachine, Burst, Entities (DOTS), Netcode for GameObjects, Visual Scripting, XR Interaction Toolkit, и каждый из этих пакетов имеет манифест с зависимостями, версиями и совместимостью, и проект под Unity сегодня собирается не как «весь Unity внутри плюс ваш код», а как «маленькое ядро Unity плюс набор выбранных вами пакетов плюс ваш код».
Это даёт Unity возможность экспериментировать с новыми подсистемами, не ломая совместимость в основном движке, и поэтому новая система рендера выходит как пакет в превью, обкатывается в нём пару лет, и если приживается, переходит в основную линейку.
Bevy довёл идею до радикального предела, в нём вообще нет понятия «ядро движка», есть App и набор плагинов, и буквально каждая фича, включая рендер, окно, ввод, ECS, время, регистр типов, добавляется в App через app.add_plugin(…), и проект, в котором отсутствует плагин рендера, спокойно собирается и запускается как headless-сервер, потому что ядро в нём настолько маленькое, что без половины плагинов реально работает.
Godot пошёл по другому пути, у него есть жирное монолитное ядро, но снаружи висит механизм GDExtension, позволяющий писать плагины на C++, Rust, Swift, любом языке с C ABI, и подключать их к движку через тот же набор виртуальных интерфейсов, которые движок использует для своих собственных нодов.
Все три подхода это микроядро разной степени радикализма, и интересно, что чем больше команда и чем чаще проекту нужно собираться в разных конфигурациях (мобайл, консоли, веб, headless server), тем сильнее давление двигаться в направлении Bevy и тем меньше команда хочет оставаться Godot.
Микроядро и моддинг
Отдельной строкой в истории игр стоит id Tech 3 и его QVM (Quake Virtual Machine), которые Кармак начал делать аж в 1999 году и которая до сих пор являются учебным примером того, как микроядро в играх может одновременно решать задачи моддинга, кросс-платформенности и обновляемости геймплея независимо от движка.
Идея была сделать так, чтобы весь геймплейный код Quake III (физика игрока, оружие, AI ботов, UI меню, серверная логика) был написан на C, но не компилируется в нативный код, а через специальный компилятор LCC компилируется в bytecode для собственной виртуальной машины QVM, и в рантайме движок загружает три отдельные QVM-программы (cgame.qvm для клиентского геймплея, ui.qvm для меню, qagame.qvm для серверной логики), исполняет их в собственной песочнице с очень узким набором вывзовов наружу, и публично документирует этот набор системных вызовов для моддеров.
Эффект был фантастический. Во-первых, моддеры смогли менять геймплей Quake III, не имея исходников движка, и от этого родились Defrag, CPMA, OSP, Urban Terror, который потом стал отдельной игрой, и десятки других модов.
Во-вторых, поскольку QVM выполнялась внутри песочницы, у моддеров не было возможности писать туда вирусы или читы, обращающиеся к памяти движка напрямую, и это до сих пор остаётся одним из самых элегантных решений проблемы безопасности в моддинге.
В-третьих, бинарный QVM-формат был кросс-платформенным, и один и тот же .qvm-файл работал на Windows, Linux и Mac, благодаря чему мод-сцена в Quake III была одной из самых интернациональных в индустрии.
А еще движок мог обновлять сам себя, не заставляя обновлять геймплей, и наоборот, потому что граница между ними была в сишном коде, а в абстрактном API виртуальной машины. Через пятнадцать лет ровно тем же путём пошли Roblox и Dota 2 с Counter-Strike 2, в которых внутренний скриптинг геймплея устроен как микроядро с песочницей, и игровой контент авторов внутри игры это, по сути, плагины к движку, которые могут делать только то, что движок им разрешил.
Поэтому если вы видите, что новая шляпа в TF2 или новый предмет в Dota 2 приходит в игру через обычное обновление контента, а не через патч движка, вы видите ровно тот же приём, что Кармак придумал в 1999.
Source Engine и microkernel
Source Engine у Valve это случай, когда идея микроядра растеклась по всему движку до такой степени, что её стало сложно отличить от подсистем. Source состоит из набора DLL (engine, client, server, vphysics, materialsystem, studiorender, vguimatsurface и десятка других), каждая из которых при загрузке экспортирует функцию CreateInterface(const char* name, int* return_code).
Движок при старте обходит эти DLL по списку, спрашивает у каждой по очереди интерфейсы по их версиям (VEngineClient013, IPhysics032, IStudioRender026), складывает в общий реестр, и дальше любой код в любой DLL может через тот же CreateInterface получить указатель на сервис любой другой DLL.
Это идея микроядра в чистом виде, потому что ядром движка по сути является только эта самая регистрация сервисов плюс пара диспетчеров команд и cvar-ов, а вся остальная функциональность реализована как набор сервисов, подключаемых через стабильный ABI.
Valve этим механизмом одновременно делают и моддинг, и собственную сборку движка из независимо разрабатываемых модулей, но цена такого подхода становится понятна, если попытаться обновить version-номер одного из интерфейсов в Source, теперь любая DLL, которая запрашивала старый номер, после обновления упадёт с nullptr от CreateInterface, и поэтому в Source нельзя просто так взять и добавить метод в IPhysics, его нужно добавить как IPhysics033, дать старый IPhysics032 как алиас с прокси-реализацией, и поддерживать обе версии параллельно, пока все моды и продукты не переедут.
Это та самая цена «стабильного ABI», о которой я говорил в начале раздела, и она в больших живых микроядерных системах со временем превращается в основную статью расходов разработки.
Где микроядро ломается
Обычно это несколько мест, в каждом из которых бы хотя раз разработчик бывал и плакал.
Первое и самое больное это хрупкий ABI, то есть нестабильность бинарного интерфейса, потому что микроядро радикально зависит от того, что плагины собраны под тот же ABI, что и ядро. Стоит ядру поменять размер базовой структуры, добавить виртуальный метод в середину таблицы, переключить компилятор с одной версии на другую или включить новый флаг оптимизации, и все плагины, собранные старой версией, в лучшем случае падают на старте, в худшем работают и портят память.
У UE5 это решается жёсткой привязкой плагинов к версии движка вплоть до bugfix-релиза; у Skyrim движка это решается тем, что сообщество держит несколько форков SKSE под разные версии бинарника; у Source это решается номерами интерфейсов.
Каждое из этих решений стоит времени и людей.
Вторая проблема это производительность вызовов через ядро, потому что каждый раз, когда плагин зовёт сервис ядра, мы получаем виртуальный вызов через интерфейс, обращение к таблице плагинов, иногда переключение между DLL с пересечением сегментов кода и кэш-промах по адресу другой подсистемы.
Для редких операций это бесплатно, а для тех мест, где плагин вызывает ядро десять тысяч раз за кадр, это начинает ощутимо влиять на время кадра. В долгосрочной перспективе всегда возникает компромисс между чистотой идей микроядра и стоимостью каждого пересечения границы ядро/плагин.
И последнее это сложность отладки, потому что в такой системе ошибка в плагине внешне выглядит как ошибка в ядре, ошибка в ядре выглядит как ошибка в плагине, а двойная ошибка выглядит как «ну у нас всё развалилось, и где конкретно непонятно».
Хорошие системы вкладываются в трассировку вызовов через границы, в детальный лог реестра плагинов и в crash-репорты, в которых пишется, какой именно плагин какой версии вызвал ядро в какой версии в какой момент, и без этой инфраструктуры вся система превращается в чёрный ящик, в котором никто ничего не понимает.
Когда microkernel брать и когда не стоит
Брать микроядро разумно, когда у вас есть внешняя экосистема, которой вы хотите дать возможность расширять игру или движок без пересборки. Это касается прежде всего моддинг-ориентированных игр (Skyrim, Minecraft, Factorio, Cities: Skylines, Civilization, ARK, Rimworld), движков общего назначения (UE, Unity, Godot, Bevy, CryEngine, O3DE), и live-service игр с регулярными контентными обновлениями (Dota 2, CS2, Fortnite, Roblox), потому что во всех трёх случаях вы заранее знаете, что список фич не закрыт, что часть их будет жить отдельно от ядра, и что вы готовы заплатить за стабильность ABI и инфраструктуру для расширений ради этой возможности.
Также имеет смысл испьзовать микроядро внутри одной команды, если у вас есть несколько продуктов на одном движке (как у Activision с Call of Duty на собственном движке, или EA с Frostbite, или Sony Interactive с Decima между Killzone, Death Stranding и Horizon), потому что разные команды над разными играми могут жить с одним и тем же ядром и собственными наборами плагинов.
Не брать microkernel разумно, когда у вас одна игра, маленькая команда и закрытый список фич, потому что в этом случае дополнительный уровень переходов через реестр плагинов, а это чистая трата времени и бюджета кадра, и вам куда полезнее будут обычные подсистемы и пара utility-библиотек.
И особенно эту идею не стоит брать, если у вас нет ресурсов на поддержку стабильного ABI и инфраструктуры расширений, потому что микроядро без стабильности это как Петька без Василь Иваныча, ну т.е. можно, но смысл анекдотов уже не тот.
+--------------+
| Plugin |
| Audio |
+-------+------+
|
+------+ | +------+
|Plugin| | |Plugin|
|Render|---+ | +-----| Net |
+------+ | | | +------+
v v v
+---------------+
| |
| Microkernel | <-- маленькое стабильное ядро
| (services, | со стабильным ABI и реестром
| registry, | расширений
| discovery) |
| |
+---------------+
^ ^ ^
| | |
+------+ | | | +------+
|Plugin|---+ | +---|Plugin|
| AI | | | Mod |
+------+ | +------+
+-------+------+
| Plugin |
| VFS pak |
+--------------+Если попытаться сформулировать какое-то правило, то оно звучит что микроядро это паттерн с очень узкой, но глубокой зоной применимости, и за пределы этой зоны его тащить не надо, потому что он не про модульность вообще, а про открытость списка фич во времени.
Если ваш проект открыт во времени, потому что вы делаете движок, моддерскую игру или live-service платформу, microkernel окупится сторицей и довольно быстро. Если же вы делаете одиночную игру с фиксированным списком фич, маленькой командой и однократным релизом, микроядро быстро превратится в дорогое украшение, которое съест бюджет и ничем за него не вернёт.
Blackboard

Blackboard это наверное, самый абстрактный из всех "больших" паттернов, его очень любят на конференция в статьях, и очень не любят писать руками в проде. При этом он каким-то странным образом просочился в современную геймдев-индустрию практически везде, только под другими именами и в перемешку с другими паттернами.
История у него достаточно почтенная и пришла из исследований AI семидесятых годов, когда в Carnegie Mellon пытались распознавать речь и поняли, что один специалист справиться не может, и заставили работать вместе нескольких специалистов разной природы (по фонемам, по словам, по грамматике, по семантике), каждый из которых смотрел на общую структуру данных, видел там промежуточные гипотезы своих коллег, и писал туда же свои собственные.
Получилась идея, что вместо того, чтобы заставить ваши модули звать друг друга напрямую через интерфейсы, надо поставьте между ними общую доску и пусть каждый модуль независимо публикует на эту доску свои наблюдения, гипотезы и выводы, и независимо же подписывается на изменения, не зная или не желая знать, кто конкретно их пишет.
Если, как помните, слой это «общайся через мой фасад», а подсистема это «давайте немножно знать друг о друге», то blackboard это уже «никто никого не зовёт, мы общаемся фактами на общей доске». И собственно всё...
Если слои стоят вертикально, подсистемы россыпью кружочков, конвейер стрелкой, мироядро звездой, то blackboard выглядит как большая жирная прямоугольная плоскость в центре, а в неё с разных сторон смотрят сидящие вокруг плагины, а стрелочек между плагинами нет вовсе.
Чем blackboard принципиально отличается от подсистемы с общим объектом? Этот вопрос неизбежно возникает, потому что подсистемы у нас уже общаются через общий объект World, и кажется, что blackboard это то же самое, только сбоку и прибито гвоздями.
Но во-первых, в blackboard нет понятия «владелец данных», любой агент/плагин имеет право писать любой ключ, и никто формально не считается источником истины, а в подсистемах же владелец почти всегда есть, физика владеет физическим миром, рендер владеет очередью отрисовки, и это явно зафиксировано в коде.
Дальше в blackboard нет понятия «вызов», никто не зовет друг друга, а только пишут и читают, и эта асимметрия позволяет добавлять и убирать агентов без согласований с соседями. А еще в blackboard семантика ключей объявлена отдельно от данных, то есть набор ключей это самостоятельный артефакт, который существует независимо от того, кто и как сейчас на эти ключи опирается, и его можно отдельно редактировать в инструменте, отдельно версионировать, отдельно валидировать на отсутствие неиспользуемых полей.
Эти отличия делают blackboard отличной площадкой для очень специфической задачи, а именно для AI и принятия решений в условиях неполной информации, где у вас есть несколько источников знаний (зрение, слух, память, команда от группы, инструкция от ИИ-режиссёра, состояние мира), и где главный потребитель этой информации это уже не модуль, а «бот в игре», который раз в N тиков прибегает к доске, смотрит, что новенького, и принимает решение.
Поэтому blackboard в индустрии живёт в основном именно там, в AI, и почти не живёт там, где у вас идут хорошо упорядоченные потоки данных, обладающие чёткими владельцами.
Unreal Blackboard плюс Behavior Tree
Самая опрятная реализация blackboard в современной разработке это AI Blackboard в Unreal, и она хороша тем, что её можно открыть в редакторе и буквально увидеть на экране. Объект UBlackboardData это место, где вы заранее объявляете набор ключей с их типами (Object, Vector, Float, Bool, Enum, Name, Class), при необходимости задаёте, синхронизируются ли эти ключи между ботами одной группы, и сохраняете его как файл проекта.
Объект UBlackboardComponent это рантайм-инстанс этой схемы, по одному на бота, который умеет читать и писать значения по этим ключам, кэшировать их и слать события подписчикам, когда значения меняются.
Behavior Tree поверх этого работает как один из ботов, опрашивающий доску и его узлы BlackboardDecorator проверяют значения ключей и принимают решения о том, по какой ветке дерева идти, а его узлы периодически вычисляют новые значения и пишут их в доску, или берут текущие значения из доски и переводят их в команды нижнего слоя (movement, anim, weapon).
Параллельно с Behavior Tree доску используют другие системы движка. EQS (Environment Query System) запрашивает у мира потенциальные точки укрытия или цели и записывает результат в blackboard под именем BestCoverLocation или TargetActor. AIPerception через UAIPerceptionComponent слушает события зрения и слуха, накапливает в себе список замеченных источников стимулов, и записывает в blackboard ключ типа EnemyActor, когда видит кого-нибудь враждебного.
Группа ботов может разделять часть ключей в специальном Shared Blackboard, чтобы скоординированно атаковать одного игрока или разойтись по разным укрытиям. И с точки зрения геймплейного программиста очень важно, что в этой схеме никакой компонент не зовёт другой компонент напрямую, слух не знает про дерево поведения, дерево не знает про систему укрытий, и при этом они все слаженно работают за счёт того, что у них общая структура данных и общий язык ключей.
Это даёт Unreal очень характерные плюсы и характерные минусы. Из плюсов сразу видно, что новый AI-программист может добавить совершенно новый источник знаний (например, систему слышимости огня вне зависимости от прямой видимости), просто написав компонент, который создает ключ LastGunfireLocation в доску, и поведение начнёт это видеть и реагировать, не требуя правок в коде.
Из минусов столь же явно видно, что ключи это плоское пространство имён, и в большом проекте довольно скоро возникают ключи с подозрительно похожими именами EnemyActor, TargetActor, LastKnownEnemy, CurrentThreat, между которыми семантическая разница есть, но никто из новых разработчиков уже не помнит, какая именно, и появляются весёлые баги вида «бот стреляет в одного, а идет к другому». Решается это документированием доски, code review и периодической чисткой, но это, как вы понимаете, очень не «бесплатно».
F.E.A.R. и GOAP над blackboard
Знаменитый AI в F.E.A.R. (Monolith, 2005) это, наверное, самый часто упоминаемый случай использования blackboard в индустрии, хотя саму идею тогда упаковали в более узнаваемый бренд GOAP (Goal Oriented Action Planning).
Под капотом GOAP у F.E.A.R. лежал именно blackboard, на который поведение, отряд и память бота писали свои наблюдения (где я последний раз видел игрока, какое сейчас прикрытие, есть ли поблизости товарищи, давно ли по нам стреляли, сколько у меня патронов), а планировщик GOAP читал текущее состояние из доски, накладывал на него цель «убить игрока» или «отступить с честью», и через серию связанных действий с предусловиями и эффектами строил план действий на ближайшие пару секунд.
Результат для индустрии того времени был шокирующий, потому что солдаты в F.E.A.R. частенько был умнее игрков, прикрывали друг друга, отступали и заходили с фланга, и при этом за всем этим стоял не громоздкий конечный автомат на сотни состояний, а сравнительно небольшой набор действий и общая доска, которую все агенты держали в актуальном состоянии.
Самое поучительное в этой истории то, что разработчики потом много раз публично рассказывали, и в GDC Vault до сих пор лежит его доклад «Three States and a Plan: The AI of F.E.A.R.», что большая часть разработки ушла не на сам планировщик, который был относительно прост, а на настройку blackboard. На придумывание правильного набора ключей, на правила, кто и когда их обновляет, на дисциплину «не пишите в доску то, что вы только что прочитали из неё же, не учитывая дельту», на профилирование, потому что доска опрашивалась тысячи раз в кадр на десятках ботов, и любой getter сразу становился узким местом.
Это очень характерная для blackboard-систем в мире игры, когда сам паттерн прост, а 90% работы лежит в обращении с ключами и в инструментарии для отладки.
Halo 2 и blackboard как архитектура
Halo 2 принес, наверное самый смелый шаг в разрабоки ботов, показав что всю AI-архитектура можно сделать на blackboard. У них боты были фактически набором поведений, каждый из которых смотрел на доску и принимал решения, при этом сама доска была иерархической, и у каждого бота была собственная локальная доска, у группы ботов общая доска отряда, и у миссии глобальная доска уровня, и через эту иерархию информация поднималась снизу вверх и опускалась сверху вниз.
Когда командир отряда говорил «отступаем», он на самом деле писал ключ SquadOrder = Retreat в доску отряда, и каждый член отряда видел это изменение и реагировал по-своему: один начинал кидать гранату прикрытия, второй полз к укрытию, третий лечил товарища.
Что даёт нам одно полезное наблюдение о том, что blackboard прекрасно работает с иерархией кооперации, и что разные уровни принятия решений могут жить на разных досках одного формата, и это естественно ложится на «индивид, группа, армия, кампания». Потом пналогичный подход взяли Killzone 2/3, в The Last of Us, и Crysis, и в любом более-менее современном тактическом шутере, в котором есть командные действия противника, скорее всего, под капотом сидит та или иная вариация иерархического blackboard.
The Sims и blackboard наизнанку
The Sims сделали одну из самых элегантных вариаций blackboard, в которой доской по сути является сам игровой мир, а агентами симы. Идея, описанная в публикациях Кена Форбуса и Уилла Райта и в куче последующих интервью команды Maxis, состоит в том, что объект в The Sims (диван, холодильник, ванна, телевизор) сам себя рекламирует симам через систему "поинтов", диван говорит «я даю +Comfort -Energy если на меня сесть», холодильник говорит «я даю +Hunger, но требую идти за 10 клеток», ванна говорит «я даю +Hygiene если ты не на работе», и эти "поинты" выкладываются в общее пространство, на котором симы вокруг могут читать их и сравнивать с собственными потребностями.
Сам сим в каждый момент времени имеет вектор потребностей (Hunger, Energy, Bladder, Hygiene, Social, Fun, Comfort, Environment), смотрит на все "поинты" вокруг, считает для каждой полезность как функцию своих потребностей и стоимости пути, выбирает лучшую и идёт её исполнять.
Это очень красивая инверсия blackboard, потому что доска тут не отдельная структура, а сам мир в роли доски, и расширение игры новыми объектами автоматически расширяет доступные возможности симам без правок их AI.
В этом подходе совершенно бесплатно работают расширения и DLC и новый объект в The Sims 2 Pets просто показывает свои собственные "поинты", а симы начинают его использовать, не зная заранее о существовании этого объекта, и никто не пишет ни строчки нового кода AI.
Я, кстати, считаю, что The Sims представляют самую элегантную реализацию blackboard в индустрии, и любому, кто планирует строить AI на blackboard, стоит посмотреть как игра устроена, чтобы прочувствовать диапазон возможностей.
Где еще живёт blackboard
Если посмотреть внимательно, то blackboard в виде «общая структура данных, через которую разные модули обмениваются фактами» прокрался в современную индустрию в очень многих местах. Просто называется по-разному.
Influence maps в RTS, начиная с Empire Earth и StarCraft и заканчивая Total War: Warhammer III и Company of Heroes 3, это самый натуральный blackboard, в котором клетки карты выступают ключами, а разные источники знаний (вражеские юниты, дружественные юниты, ресурсы, опасные зоны, ИИ-цели) записывают в эти клетки свои числовые оценки, поверх которых ИИ-стратег принимает решения о направлении атаки и обороны.
Threat tables в MMO, в World of Warcraft, FFXIV, Guild Wars 2, это другой blackboard, в котором ключи это список игроков, а значения это накопленная агрессия, и боссы читают эту доску каждый тик и решают, на кого переключиться.
ECS как архитектурный приём тоже можно прочитать как глобальный blackboard, в котором ключи это (Entity, ComponentType), а агенты это системы, которые читают и пишут компоненты. Эта аналогия не натянута, потому что в EnTT, flecs, Bevy ECS, Unity DOTS доступ к данным идёт именно через декларацию «я пишу эти компоненты, я читаю те», и сам мир (World или Registry) выступает доской, а системы как агенты. Вряд ли исследователи 70-ч могли это предвидеть, но в современном виде это выглядит так, что blackboard разросся, превратился в полноценную data-oriented архитектуру и стал стандартным способом писать серверный код в современных движках.
К тому же ряду стоит добавить GameplayTags в Unreal и собственные системы тегов в Frostbite и Decima, которые суть очередное имя на доске без значения (или с подразумеваемым значением «true»), и World State в строгом смысле GOAP-планировщиков, и AI Director в Left 4 Dead, который смотрит в общую доску эмоционального состояния игроков (давно ли стреляли, насколько разделена группа, сколько прошло времени после атаки) решает, когда устраивать следующий наплыв зомби. Когда вы видите в архитектурной диаграмме игровой системы большую центральную «World State», к которой стрелки тянутся со всех сторон, это и есть blackboard, просто переименованный для удобства рекламщиков.
Где blackboard ломается
Главных проблем несколько. Первая это гонки на доступе к данным, потому что blackboard по своей идее разрешает множественную запись одного и того же ключа, и если у вас за один тик три источника знаний обновляют ключ EnemyActor своими версиями (один по зрению, второй по слуху, третий по команде отряда), то итоговое значение определяется тем, кто записал последним, а это сильно зависит от того, в каком порядке движок обновляет компоненты, и любое изменение этого порядка молча меняет поведение AI.
Лечится это либо явной приоритезацией источников, либо разнесением ключей на отдельные доменные пространства («что я знаю по зрению» отдельно от «что мне сказал командир»), либо вводом версионированием данных в доске, в котором сохраняется источник и время каждой записи, но каждое из этих решений добавляет инфраструктуру и нагружает разработчика.
Вторая это неявные зависимости через имена ключей, и это, пожалуй, самая характерная болезнь blackboard. В коде нет никаких прямых связей между двумя агентами, кажется, что они независимы, и можно безбоязненно изменить одного из них, но если этот агент перестал писать ключ BestCoverLocation, или начал писать его раз в десять кадров вместо каждого, или поменял семантику с «лучшая точка укрытия» на «ближайшая», то весь AI, который опирался на эту доску, начинает вести себя по-новому.
В небольшом проекте это лечится дисциплиной и парной разработкой, в большом проекте этого не вылечит уже никто, и кроме формальных схем доски с указанием правил для каждого ключа и автоматических тестов изменения никакой управы нет.
И последняя проблема это производительность. При росте числа агентов и ключей ведёт себя очень плохо: когда у вас 10 ботов и 20 ключей, опрос доски бесплатен, но когда у вас 200 ботов и 200 ключей с валидацией, то доска внезапно становится горячей структурой данных, и каждое чтение через FindKey(name) либо хеш-таблицу, либо массив с линейным поиском начинает заметно жрать перф.
Лечится это переходом от строковых имён ключей к индексам, компиляцией в плоскую структурас прямым доступом по offset-у, или отдельными быстрыми путями для самых горячих ключей, что в итоге превращает blackboard в структуру в духе ECS, в которой ключ это просто номер компонента, а агент это просто система с декларированным фильтром.
Когда выбирать blackboard
Brать blackboard стоит, когда у вас в системе одновременно есть множество независимых источников знаний разной природы, которым неудобно знать друг про друга. Это, как правило, AI с зрением, слухом, памятью, союзниками, директивами от командира и от ИИ-режиссёра, и любое жёсткое связывание этих источников через прямые вызовы создаёт паутину кода.
Когда есть несколько независимых потребителей этой информации, которые тоже неудобно складывать в один большой объект «мозг бота», а в современных AI это разные подсистемы того же бота (planner, movement, animation hints, combat hints), плюс соседние боты, плюс ИИ-режиссёр.
И когда есть готовность вложиться в инструменты для отладки, потому что без дебаггера данных, истории её изменений и проверки, blackboard очень быстро превращается в чёрный ящик, в котором никто ничего не понимает.
Если все условия выполнены, то blackboard даёт замечательную декомпозицию AI на независимые модули и хорошо масштабируется, и я не зря привожу столько примеров из больших шутеров и стратегий, потому что именно там эта связка модулей оправдана. Если же хотя бы одно условие не выполнено, то blackboard принесёт больше проблем, чем пользы, потому что вы получите дополнительную косвенность данных или независимость источников без множества потребителей, или то и другое без инструментов, и в итоге у вас на руках все равно окажется паутина кода.
BLACKBOARD
+------------------------------------------------+
| EnemyActor = Player_3 |
| LastSeenAt = (123, 45, 8) |
| BestCoverPoint = (130, 50, 8) |
| SquadOrder = Flank_Left |
| Suspicion = 0.72 |
| TimeSinceShot = 1.8 |
+-+----------+----------+-----------+-------------+
^ ^ ^ ^
| write | write | write | write
| | | |
+-+--+ +--+---+ +--+----+ +--+-----+
|Sight| |Hearing| |Squad | |Director |
|Sense| |Sense | |Comms | |AI |
+-----+ +-------+ +-------+ +---------+
v v v v
| read | read | read | read
| | | |
+-+--+ +--+---+ +--+----+ +--+-----+
| BT | | EQS | | GOAP | | Anim |
| | |Query | |Planner| |Hints |
+----+ +------+ +-------+ +--------+Если попытаться сформулировать правило применения, то blackboard в чистом виде это паттерн ровно одной области. AI и принятия решений в условиях нескольких независимых источников знаний и нескольких независимых потребителей, и в этой области ему нет адекватной замены.
Попытки обойтись прямыми вызовами и подсистемами приводит к паутине кода, которую через год невозможно расширять. Но за пределами AI blackboard почти всегда либо переходит в другой паттерн под другим именем (ECS, GameplayTags, Influence Map, World State, Threat Table), либо служит дорогой и громоздкой подменой более простых решений, а помесь blackboard с другими паттернами это норма, и она в общем-то так и должна работать.
Стратегии проектирования

Если "большие" паттерны, описаные выше, отвечают на вопрос «как мы упакуем код в кирпичики и какой формы они будут », то стратегии проектирования отвечают на куда более болезненный вопрос «а с какого конца мы вообще будем эти кирпичики класть». На этой теме вообще редко кто заостряет вниманием, потому что в индустрии очень любят думать что «мы делаем сверху вниз» или «мы делаем снизу вверх», и почти никто не задумываются о том, что само направление проектирования это тоже архитектурное решение, которое надо принимать в начале проекта, а не получать по желанию левой пятки тимлида после прочтения очередного опуса.
Игра не дизайнится сверху вниз или снизу вверх или из середины. В реальности есть три более-менее различимых направления, каждое из них которых своеобразный компромисс между разными силами притяжения.
Top-down разработка, назовем её так, хорошо работает там, где уже есть сильный геймдизайн или продуктовая концепция, и весь технический риск состоит в том, что нужно построить заранее известный список систем.
Bottom-up хорошо работает там, где есть сильная технология, и игра в каком-то смысле собирается вокруг неё, а потому уже идет игровой дизайн, системы и все остальное.
А constant refactoring работает там, где нет ни того, ни другого, а реальные требования рождаются прямо в процессе игры, и вы готовы платить за это переписыванием по два-три раза каждой подсистемы.
Top-down: детали потом
Top-down это подход, в котором вы садитесь в начале проекта и раскладываете его от формулировки «что у нас за игра» через список систем («экономика, бой, дипломатия, ИИ, многопользовательский режим, мета-прогрессия») до конкретных модулей и интерфейсов внутри каждой системы.
На бумаге выглядит очень красиво, и любой инвестор, любой менеджер и любой геймдизайнер в первой половине проекта будет вас за это любить и носить на руках, потому что top-down выдаёт красивые диаграммы, много красивых.... очень много красивых... ну вы поняли... , понятные планы и предсказуемые оценки сроков. Любой проект-менеджер отдаст правую почку за возможность работать на таком проекте, который обычно начинается с текстового дизайн-документа страниц эдак триста-четыреста.
Top-down работает, когда игра сиквел или триквел и уже достаточно большая, когда вы делаете Civilization VII, FIFA, Football Manager, Call of Duty или очередной Assassin’s Creed, и у вас на стене висит предыдущая часть, у вас есть телеметрия о том, в какие подсистемы игроки чаще всего ходят и какие фичи нужно усилить, и у вас есть команда, которая два года назад уже собирала ровно такую же архитектуру, поэтому top-down тут нормальная инженерная работа по уточнению заведомо рабочей конструкции.
Еще top-down работает для больших MMO и live-service проектов крупных команд в духе World of Warcraft, Final Fantasy XIV, Destiny 2, EVE Online, потому что у этих проектов уже есть платформа, есть инфраструктура серверов, есть отлаженный пайплайн контента, и каждое следующее расширение это в первую очередь декомпозиция новой фичи в уже существующий стек игры.
Расплата за top-down прячется в слове «реальность», потому что тop-down разработка смертельно не любит, когда плановая большая картина не выживает первого столкновения с реальным билдом. Потому что вверху уже отстроены интерфейсы и модули под старую картину, а новая картина требует другой декомпозиции, и любой переход означает либо переписать половину уже сделанного, либо натянуть новую сову на старый глобус, сделав приятное глобусу и при это не сильно огорчив сову. Повторить три раза до релиза.
Поэтому top-down в инди и в новых IP почти всегда заканчивается одним из двух: либо команда выпускает игру, которая ощущается как «правильно сделанная, но не интересно играть», потому что геймдизайн не успел дозреть до момента, когда система уже готова; либо команда после года top-down проектирования переходит в режим constant refactoring и переписывает половину архитектуры, потеряв еще год.
С точки зрения процесса top-down даёт ещё один характерный побочный эффект: он плохо терпит поздно пришедших программистов. Если на проекте, начатом top-down, через год работы появляются новые разработчики, им нужно прочитать всю ту тонну документов, которые появились с момента рождения проекта, проникнуться его принципами и научиться писать в его стиле.
Стоимость онбординга на таких проектах оказывается значительно выше, чем в bottom-up или constant refactoring, потому что в top-down проекте архитектура сильно вертикальная и одна часть мало понятна без понимания другой. С другой стороны, как только онбординг прошёл, человек становится высокопродуктивным и это во многом объясняет, почему top-down хорошо работает в больших устоявшихся студиях с медленной ротацией кадров и плохо работает в стартапах, где часть команды меняется за год.
Bottom-up: сначала технология, игра потом
Bottom-up это противоположный подход, в котором вы начинаете не с дизайн-документа, а вот прямо с куска технологии, которую вам хочется или жизненно необходимо иметь, и потом собираете игру вокруг этой технологии, выбирая жанр и механики так, чтобы они максимально эту технологию использовали.
На бумаге это выглядит очень кустарно, потому что любая бизнес-методология последних двадцати лет требует начинать с пользователя, с продукта и с маркетинга, а инженер, который заявляет «я сначала напишу рендер, а потом мы подумаем, какую игру под него делать», воспринимается как м..., которого по недоразумению подпустили к разработке. Но на практике bottom-up это один из самых рабочих подходов, и через него прошла половина легендарных игр.
Канонический пример это id Software первой половины девяностых, когда сначала Кармак писал быстрый растеризатор и софтверный 3D-движок, а потом команда думала что с этим делать и какая игра лучше всего покажет и этот растеризатор и этот движок.
Еак появился Wolfenstein 3D, потом Кармак написал новый движок, в котором появились bsp-деревья и портальный рендер, и под них была собрана DOOM, потом он сделал полноценные 3D, и под него собрали Quake.
В каждом случае технология приходила первой, дизайн собирался под неё, и это было осознанным выбором команды, который описан в массе интервью и в книге Дэвида Кушнера «Masters of Doom».
Тот же подход был у Bullfrog в эпоху Theme Park и Dungeon Keeper, у Frontier Developments с Elite, у Origin Systems с Wing Commander и Ultima Underworld, и у Crytek с первым Far Cry, который изначально задумывался как технологическая демонстрация CryEngine 1.
И, конечно, мои любимые Naughty Dog, у которых вокруг собственного DSL GOAL (Game Oriented Assembly LISP), а позже GOOL и ICE, выстраивали целые проекты от Crash Bandicoot и Jak and Daxter до раннего Uncharted, и геймплей очень сильно опирался на специфические возможности этих языков.
GOAL does not run in an interpreter, but instead is compiled directly into PlayStation 2 machine code for execution. На консоли язык компилировался в системые и движковые вызовы наравне с С/С++.
Первый Uncharted не использовало GOAL, к этому моменту Naughty Dog уже перешли полностью на C++ под давлением Sony.
«Naughty Dog had to create its own tools for GOAL... Naughty Dog started using GOAL again. They used it for scripting in some PlayStation 3 games. This included The Last of Us».
То есть Lisp вернулся как скриптовый язык в PS3-эпоху, а не как основной язык геймплея. Uncharted 1 и 2 были написаны на C++ с собственными скриптовыми системами.
Bottom-up работает там, где технология сама по себе является продаваемой вещью, то есть когда игроки приходят к вашей игре в первую очередь ради того, что другие не делают. Первый Crysis с дальностью прорисовки и динамической растительностью, первый Half-Life 2 с физикой Havok как геймплейным элементом, первый Portal с порталами как самой механикой, первый The Last of Us Part II с симуляцией лиц через motion matching, первый Microsoft Flight Simulator 2020 с реальным миром на стриминговых данных.
Оригинальный движок, лицензированный Valve это Ipion Virtual Physics (IVP), был куплен Havok в 2000 году... и позже IVP решения влились в Havok.
Еще он работает там, где технологическая основа уникальна и плохо переиспользуется, вроде воксельных движков Minecraft, Star Citizen с бесконечным миром, или в исследовательских проектах, где вы заранее не знаете, какая игра получится, и обнаружение свойств технологии и есть основная цель.
Расплатой за bottom-up будет риск получить красивую технологию без игры, и индустрия эту цену платила много раз. Техдемо делают, презентуют на E3, получают аплодисменты, идут к издателю, а через два года либо проект закрывается, потому что вокруг технологии не собралось игры, либо выходит коммерчески слабая игра, в которой технология есть, но она не нужна никому, кроме её авторов. История знает много таких случаев, и я не буду показывать пальцем на конкретные проекты, потому каждый хоть немного играл в игры, может назвать пару примеров из головы.
Вторая цена bottom-up это сильная зависимость от ключевого человека, который держит технологию в голове. Движок id Tech 1 был большим куском работы Джона Кармака, его уход или болезнь немедленно ставили под угрозу весь продуктовый портфель компании, то же самое было и в Bullfrog с Питером Молинё на ранних этапах, и в Naughty Dog с Andy Gavin до того, как ICE Team стала командой. Bottom-up часто растут без культуры передачи знаний и без жёсткой дисциплины документирования, превращаяся в проекты с басфактором = 1.
Constant refactoring: переписываем все, но понемногу
Третий подход это constant refactoring, и он отличается от первых двух тем, что у него нет фиксированного направления вообще. Вы можете начать с любого конца, с какой угодно стороны, пишете самый простой работающий вариант любой подсистемы, играете в собственный билд, понимаете, что именно вам не нравится, переписываете эту подсистему, играете снова, и так раз за разом, пока через год или три не получается игра, в которой каждая подсистема пережила минимум три-четыре переписывания, а критичные системы по пять-шесть, и при этом игра ощущается хорошо, потому что каждый раз решение проблемы принималось по результатам собственной игровой сессии.
На бумаге это выглядит как дикостью, и если вы придете с таким проектом к инвестору, вас очень мягко так пошлют на север. Любой эффективный менеджер будет утверждать, что constant refactoring это просто нежизнеспособная идея и «у вас нет архитектуры», но в реальности у большинства инди-игр под капотом идет именно constant refactoring разработка, и они от этого ничуть не стали хуже.
Minecraft в самые ранние годы был чистым constant refactoring, и Маркус Перссон в своём блоге довольно открыто описывал, как он по нескольку раз переписывал систему чанков, систему освещения, систему лава-вода-физики, рендер кубов, потому что в каждой следующей сессии он понимал, что предыдущая версия не выдерживает того, что он хочет от игры.
Factorio прошла через хорошо известные индустрии переписывания на разных уровнях стека, от рендера до симуляции лент, а в Factorio Friday Facts регулярно описывают такие переписывания публично, иногда с детализацией, которая в больших студиях считается коммерческой тайной.
Dwarf Fortress братьев Adams это самый длинный constant refactoring в истории индустрии, потому что братья двадцать лет переписывают один и тот же проект, постепенно расширяя его возможности и регулярно ломая совместимость с предыдущими версиями.
Stardew Valley это другой образец того же подхода, в котором одиночный разработчик за четыре года несколько раз переписывал половину кода движка, систему диалогов, систему ферм, и в результате выпустил игру, которую сегодня многие называют образцом ремесла.
Constant refactoring работает когда сам факт того, какая игра у вас получится, заранее неизвестен, и обнаружение этого факта возможно только через игру в собственный билд. Это касается, во-первых, прототипов, потому что прототип по определению не знает, во что он превратится. Во-вторых, инди-проектов, в которых единственный или маленький коллектив авторов хочет, чтобы игра ощущалась как продолжение их идей, а не как реализация заранее написанного документа.
В-третьих, проектов в раннем доступе, потому что для них это нормальный стиль развития, когда вы выпускаете рабочий, но неполный билд, собираете отзывы, переделываете и снова выпускаете, и так несколько лет. Steam Early Access за десять лет существования закрепил эту модель как полноценную бизнес-стратегию.
Расплата за constant refactoring тоже своя и тоже довольно болезненная. Главная цена это стоимость переписываний, потому что переписать подсистему это всегда дорого, особенно если за время её жизни в неё успели вклиниться другие подсистемы, на неё успели опереться художники с ассетами или сценаристы с диалогами. Каждое переписывание имеет ненулевой шанс сломать что-то в соседних системах и заставить переделывать соседнюю систему тоже.
В реальности проекты на constant refactoring часто живут с длинными ветками рефакторингов в Git, со сложными процессами слияния, с регрессиями, появляющимися на ровном месте после каждого нового обновления, и любой инди-разработчик, который сидит на constant refactoring, скажет вам, что половина его рабочего времени уходит не на новый код, а на «починить то, что три месяца назад работало».
В студиях больше 5-7 человек constant refactoring обычно начинает резко терять эффективность, потому что параллельная работа нескольких людей над сильно меняющимся кодом приводит к постоянным мерж-конфликтам и взаимным обидам, и это одна из причин, по которой большие команды чаще тяготеют к top-down даже там, где сам проект жизненно просит constant refactoring.
Еще есть риск получить вечный рефакторинг без игры, просто когда у автора нет внешнего давления в виде дедлайна, инвестора или соглашения с издателем, constant refactoring может незаметно стать самоцелью. Каждая итерация улучшает архитектуру, но не приближает к релизу, и через пять лет проект имеет великолепный код и аккуратные подсистемы, но играть в него по-прежнему не во что.
Игроки шутят, что Dwarf Fortress находится в этом состоянии официально с самого начала, и Tarn Adams совершенно открыто говорит, что собирается делать игру до конца жизни, но это исключение, которое лишь подтверждает правило, потому что большинство проектов, которые так живут, мы просто никогда не увидим.
Что в реальности происходит на проектах
В чистом виде ни один из трёх подходов в крупном проекте обычно не доживает до конца, и почти всегда команда делает гибрид, и типичная реальная траектория проекта выглядит так, что первые шесть-двенадцать месяцев это bottom-up с элементами constant refactoring, потому что команда строит технологическое ядро, делает прототипы и щупает игру.
Следующие полтора-два года это top-down, потому что прототип утверждён, дизайн зафиксирован, и нужно по плану построить остальные системы и контент, а последний год это снова constant refactoring, потому что плейтесты и фокус-группы выявляют проблемы, которые надо лечить, и переписывания крупных систем в это время практически неизбежны. Это, кстати, ровно одна из причин, по которой многим разработчикам нравится работать в начале и в конце проекта и не нравится в его середине, потому в середине команда живёт в максимально top-down режиме, в котором каждый шаг расписан и пространства для манёвра меньше всего.
Выбор направления влияет и на структуру команды, и на структуру кода. Top-down прекрасно масштабируется на сотни людей, потому что дизайн раздаётся вниз по иерархии разработчикам, каждый получает свой кусок и не мешает соседу.
Bottom-up естественно ограничивает команду размером ядра, держащего технологию, плюс небольшой обвязки сверху, и поэтому он редко встречается в проектах больше 20-30 человек. А сonstant refactoring практически не масштабируется за пределы 5-7 человек, и я не зря привожу выше примеры с одиночными разработчиками, потому что constant refactoring на двадцати человек это организационный ад с постоянными конфликтами и руганью на кухне.
Поэтому, выбирая направление проектирования, вы одновременно неявно выбираете и максимальный размер команды, на котором этот выбор будет работать, и наоборот, размер команды диктует вам, какое направление в принципе возможно.
Размер команды
|
|
1-3 4-10 11-50 50+
+---------+---------+-----------+-----------+
CR | ******* | ***** | ** | . | constant refactoring
BU | ***** | ******* | ***** | ** | bottom-up
TD | * | *** | ******** | **********| top-down
+---------+---------+-----------+-----------+
* чем больше звёздочек, тем естественнее подход
для команды данного размераКак выбирать
Практическое правило, которое я бы сформулировал поверх всего этого, звучит так, что если у вас на руках сильный диздок, основанный либо на предыдущей части серии, либо на устоявшемся жанре, либо на формальной франшизе с понятными ограничениями, и при этом у вас команда больше десяти человек, выбирайте top-down, потому что вы за него заплатите чистыми инженерными усилиями, и эти усилия гарантированно превратятся в продукт. Основной риск тут это потерять связь с игрой и сделать «правильно построенный, но скучный» проект, и лечится он регулярными плейтестами с самого начала, не реже одного в неделю.
Если у вас есть технологический прототип, или вы строите движок, или собираете игру вокруг конкретной технологии, выбирайте bottom-up и берите на работу очень хорошего инженера или маленькую команду инженеров, и одновременно одного-двух геймдизайнеров, которые понимают эту технологию и умеют выдумывать механики под её возможности. Основной риск тут - это получить красивую технодемку без игры, и лечится он жёстким требованием «каждые два месяца показывать играбельный прототип», даже если он стыдный.
Если у вас инди-проект, маленькая команда и неочевидный замысел, идите в constant refactoring и не извиняйтесь за это ни перед кем. Здесь основной риск это вечный рефакторинг без релиза, и лечится он внешним дедлайном, договором с издателем или личным обещанием перед собой, что через два года вы релизитесь любой ценой.
Но какой бы подход вы ни выбрали, не объявляйте его религией. В индустрии нет места ни для «мы всегда делаем top-down», ни для «мы принципиально работаем bottom-up», ни для «у нас Agile и непрерывный рефакторинг везде», потому что любая такая формулировка означает, что направление проектирования заранее выбрано без учёта проекта, и от этой методологической слепоты страдают и проект, и команда.
Гораздо лучше относиться к направлению как к ещё одному рычагу, который вы держите в руке и в каждый момент проекта решаете, в какую сторону его сейчас довернуть, и тимлид, который умеет осознанно переключаться между top-down, bottom-up и constant refactoring в зависимости от фазы проекта, в долгосрочной перспективе делает заметно больше успешных игр, чем тимлид, который выбрал один "правильный" подход на всю жизнь и уверенно пользуется только им.
Push vs Pull

После того как вы определились с направлением проектирования, перед вами немедленно встаёт следующий вопрос, как именно ваш код будет получать актуальные значения, начиная от текущей громкости звука и заканчивая списком ассетов на текущем уровне. Будет ли он каждый раз сам бежать за этим значением туда, где оно лежит, или же его будут об изменениях оповещать те, кто эти значения меняет.
Звучит это вроде бы как мелкая техническая деталь, чуть ли не вопрос личного вкуса, но на горизонте трёх лет проекта от выбора между push и pull зависит примерно столько же, сколько от выбора между подсистемами и слоями, потому что push и pull не просто отличаются стилем вызовов, они принципиально по-разному отвечают на вопрос «кто несёт ответственность за актуальность данных».
В pull-модели ответственность за актуальность лежит на потребителе данных, то есть на вас, и когда геймплейному коду нужно узнать масштаб HUD, он зовёт game::get_setting(“hud.scale”) и получает значение в момент вызова; когда рендеру нужна 3D-модель, он зовёт render::Load3DModel(“hero.fbx”) и блокируется до того, как модель будет на руках. Когда AI нужно узнать, видит ли он сейчас игрока, он зовёт IsActorVisible(player) и считает результат на месте.
Это базовая интуиция любого начинающего разработчика, потому что в pull всё устроено максимально прямолинейно и данные хранятся где-то в стороне, вы знаете где, вы туда идёте, забираете и возвращаетесь.
В push-модели ответственность за актуальность лежит на источнике данных и когда настройка изменилась, кто-то сообщает вам об этом через OnSettingChanged(“hud.scale”, old, new); когда ассет стал доступен, AssetManager шлёт вам OnAssetReady(“hero.fbx”, handle); когда игрок попал в видимость AI, бот получает событие OnSensedPawn(player), и дальше уже ваша задача отреагировать на это событие, а не пытаться раз в кадр всех опрашивать.
В push-системе нельзя писать цикл вида for (int i = 0; i < N; i++) load("xxx%d.tga", i);,
потому что push по определению не разрешает потребителю самому решать, когда и в каком порядке грузить ресурсы, эту работу делает источник.
В pull-системе такой цикл вполне нормален и встречается на каждой странице кода, поэтому, если вы возьмёте любой проект и пробежитесь по нему grep-ом в поисках циклов с загрузкой ресурсов по индексу или по шаблону имени, вы за полчаса очень точно определите, в каком режиме на самом деле работает менеджер ассетов в этом проекте, независимо от того, что про него написано в архитектурной документации.
Часто результат расходится с документацией, потому что менеджмер заявлен в push, а половина кода ходит в pull, потому что так было быстрее написать. Та же проверка работает для настроек, для cvar-ов, для данных AI, для состояния мира и если в коде встречаются массовые опросы вида «пробегусь по всем ботам и спрошу каждого, видит ли он игрока», это pull.
Если в коде вместо этого есть подписки «уведомите меня, если хоть один бот увидел игрока», это push. Большие команды довольно часто живут в смешанном режиме именно потому, что отдельные подсистемы выбирали свой режим независимо, и через год объявление архитектуры в стиле «у нас всё реактивное» совершенно не соответствует реальной картине в кодовой базе.
Когда pull это правильный выбор
Во-первых, когда частотность изменений значения очень низкая, и потребителей при этом немного. Если ваша настройка gravity меняется раз в полгода, когда геймдизайнер двигает ползунок в редакторе, и читают её при этом два-три места в коде, никакой push-инфраструктуры заводить смысла нет, потому что вы потратите больше усилий на регистрацию подписок и обработку событий, чем сэкономите на обновлениях.
Во-вторых, когда значение легко вычислить на лету в момент вызова, и кэшировать его дороже, чем пересчитывать. Современный hash на 64 байтах, обращение к thread-local context, чтение из L1-кэша процессорной структуры, всё это в pull стоит наносекунды, и заводить push-инфраструктуру для оповещения о таких изменениях экономически бессмысленно.
И в-третьих, когда сам факт обращения за значением является семантически важным, например, при опросе системы time с указанием конкретной временной шкалы (UnscaledTime, GameTime, NetworkTime), потому что в этих случаях вы хотите получать значение ровно в тот момент, когда вы его попросили, и никакая push-инфраструктура с задержкой в один кадр здесь работать не должна.
Хорошие игровые движки оставляют pull для огромного количества системных вещей, и совершенно правильно делают. Quake и его производные (Half-Life GoldSrc, ранний Source) построены практически целиком на pull: cvar_t* sv_gravity = Cvar_Get("sv_gravity", "800", CVAR_SERVERINFO) это pull-handle на cvar, который читается из любого места кода каждый раз заново.
gi.imageindex("textures/wall01.bmp") это pull-loader, который при необходимости загружает текстуру и возвращает индекс; cl.snap.ps это pull-access к текущему предсказанному положению игрока. Это не от лени и не от незнания паттернов, это осознанное решение, и оно работает потому, что Quake это игра с относительно компактным набором ассетов, известных заранее, и без серьёзного стриминга.
Когда вокруг этого же движка пытаются построить открытый мир, pull начинает ломаться, и приходится по частям переводить подсистемы в push, и это, кстати, ровно та история, через которую прошёл Source при разработке Half-Life 2 со всеми его стриминговыми компромиссами.
Pull прекрасно живёт в геймплейных правилах, в математических утилитах, в функциях запроса конфигурации, в immediate-mode UI, в debug-командах из консоли, и попытка переписать каждое из этих мест в push приводит к раздутию инфраструктуры без видимой пользы.
Когда pull превращается в проблему
А вот где pull однозначно становится плохим решением, так это стриминг ресурсов в открытых мирах. Если у вас игра уровня GTA V, RDR2, Cyberpunk 2077, Microsoft Flight Simulator или Star Citizen, в которой одновременно в памяти может находиться лишь крошечная доля от полного набора ассетов, любая попытка делать pull-загрузку вида «понадобилась модель, загрузил, нарисовал» приведёт либо к фризам на каждом кадре, когда модель загружается в момент первой её просьбы, либо к необходимости каждому потребителю самостоятельно угадывать, что ему понадобится через секунду, и заранее за этим ходить.
Эта затея с одинаковым успехом проваливается во всех проектах, которые её пробуют. Поэтому открытые миры обязаны быть push и централизованный World Streaming Subsystem в Rockstar Advanced Game Engine, World Partition в Unreal Engine 5, Streaming System в CryEngine 5, Cell Streaming в Frostbite сам смотрит на позицию камеры, скорость её движения, направление взгляда и проактивно решает, какие ассеты грузить, какие выгружать, и шлёт игре события OnCellLoaded, OnCellUnloaded, OnAssetReady.
Геймплейный код в современном открытом мире вообще не знает, какие конкретно ассеты сейчас лежат в памяти, он работает с теми, которые ему дали, и подписан на изменения.
Еще одно место это UI и его связь с моделью данных. Когда у вас в HUD висит счётчик здоровья игрока, и вы реализуете его как pull, то есть каждый кадр UI спрашивает у actor его HP и обновляет text, это работает, но плохо масштабируется и если у вас сотня UI-элементов в open-world игре, и каждый опрашивает свою модель, то в профайлере вы видите 0.3 миллисекунды, целиком ушедшие на «UI getters».
Хуже того, при сложных HUD-элементах вы начинаете писать диспетчерскую логику «если HP изменился относительно прошлого кадра, проиграть анимацию tween», и это, по сути, плохо реализованный push поверх pull. Поэтому современные UI-системы дают вам реактивные привязки, в которых UI-элемент описывается как функция от модели, а движок сам отслеживает, какие поля модели надо переписать на push и когда заново перерисовать виджет. И сравнение этого с императивным pull-UI настолько очевидно в пользу push, что в индустрии этот переход за последние десять лет произошёл практически у всех крупных движков.
Дорого спроектированый push и дёшево написанный pull
push модель дорогая и труднорасширяемая вещь в себе и это надо принять как часть её архитектуры, а не как недостаток. Главная цена push это подписки, потому что у вас должны быть реестры подписчиков, у этих реестров должны быть thread-safe варианты для случаев, когда подписка делается из одного потока, а событие отправляется из другого, у подписок должны быть слабые ссылки, чтобы умирающие подписчики не оставляли висящих указателей, у событий должна быть гарантия доставки даже после кадровых границ, и в особо тяжёлых случаях должны быть очереди событий с приоритетами на тот случай, когда несколько систем подписаны на одно и то же событие и порядок их вызова имеет значение.
В pull у вас нет никаких подписок, объекты создаются и умирают без последствий для соседей, а в push каждый объект, который на что-то подписан, должен корректно отписываться при уничтожении, и если он этого не сделает, у вас в реестре подписчиков начинает копиться мусор, который при следующей рассылке либо упадёт с access violation, либо вызовет случайный код по случайному адресу, и эти баги лежат среди самых неприятных, потому что воспроизводятся через раз и только при определённых сценариях.
Лекарство известное, либо RAII-обёртки с автоматической отпиской в деструкторе, либо weak-pointers, либо явная регистрация подписки в lifetime-объекте вроде UScriptStruct->BindToObject(owner), который сам аннулирует подписку при смерти owner-а.
В pull, когда у вас что-то не так, вы ставите breakpoint на чтение и видите весь стек вызовов, который к этому чтению привёл. В push, когда у вас что-то не так, вы ставите breakpoint на колбэк и видите стек, в котором фигурируют только источник, диспетчер и колбэк, а сам контекст, который привёл к этому событию, может лежать совсем в другом потоке, в другом тике, а иногда вообще на другой машине, если речь идёт о сетевой репликации.
Поэтому в push-системах гораздо сильнее, чем в pull, нужны хорошие средства трассировки и event log с записью «когда и кто отправил, кто принял», breakpoint by event type в дебаггерах движка, timeline viewer для асинхронных событий, Network profiler для replication events, и команды, экономящие на этих инструментах, потом расплачиваются за каждое такое решение тысячами часов QA.
И наконец простая broadcast-рассылка, в которой одно изменение значения вызывает обновление пятисот подписчиков, теоретически O(N) и работает быстро, но на практике вы получаете кэш-промах при каждом обращении к каждому подписчику плюс пересечение виртуальных таблиц плюс ветвление в каждом колбэке, и для системы из тысячи лёгких подписчиков push легко начинает проигрывать pull на пять-десять процентов кадрового бюджета.
Поэтому push-сообщения часто сериализуются в течение одного фрейма и обрабатываются единым проходом в конце кадра, чтобы накопленные изменения объединить и обработать пакетно, и это уже не push в чистом виде, а гибрид push с очередью, который ведёт себя почти как pull в момент чтения.
Гибриды и реальная жизнь
В реальных современных движках вы практически нигде не найдёте чистого push или чистого pull, и большая часть рабочих архитектур это гибрид, в котором разные подсистемы используют разные модели по своим причинам.
Самый показательный пример это Unreal Console Variables: у IConsoleVariable есть классический pull-метод GetInt(), который читает текущее значение мгновенно, и одновременно есть push-метод OnChangedDelegate, на который можно подписаться, чтобы получить событие при изменении. Это даёт лучшее из двух миров, когда горячий код опрашивает pull-ом без накладных расходов, а подсистемы, которым нужна реакция на изменение, подписываются push-ом, и вы не платите ни за то, ни за другое там, где это не нужно.
Похожим образом устроен Asset Streaming в Unreal, где у вас есть pull-обращение LoadObject<T>(path) для случаев, когда вам срочно нужен ассет и вы готовы заблокировать поток, и есть push-обращение через StreamableManager::RequestAsyncLoad(path, FStreamableDelegate::CreateUObject(this, &Class::OnLoaded)), которое возвращает управление сразу и присылает событие, когда ассет действительно загружен. Обычно на проекте 90% обращений идут через push, а оставшиеся 10% это либо стартовые ассеты, которые грузятся в загрузочном экране, либо редкие случайные кейсы.
Сетевая репликация в современных движках это интересный случай чисто push-архитектуры, в которой источник изменений (сервер) сам решает, кому, что и когда отправлять, и клиент получает события вида «вот эта переменная теперь имеет такое-то значение». Unreal Network Replication через OnRep_FunctionName это push в чистом виде, Photon Quantum, Mirror и FishNet в Unity делают то же самое, GameNetworkingSockets в Source 2 это push на нижнем уровне с прямым контролем потоков. И здесь pull в принципе невозможен, потому что клиент физически не имеет доступа к данным сервера и не может их «вытянуть», ему остаётся только подписываться и ждать.
Когда push становится антипаттерном
В индустрии последние десять лет наблюдается мода «всё реактивное, всё на событиях», и эта мода приводит к ровно тем же проблемам, что любая другая радикальная методология. Push становится антипаттерном, когда вы используете его для простых синхронных операций, для которых pull был бы естественнее: когда геймплейный код, чтобы получить текущее время, шлёт OnTimeRequest событие и ждёт, пока на него ответят с другого конца кодовой базы, это не реактивная архитектура, это инженерное безумие.
Push становится антипаттерном, когда у вас частотность событий заведомо высокая и подписать AI каждого бота на push-событие OnPlayerPositionChanged, которое прилетает каждый кадр, это просто более дорогой способ опросить позицию игрока pull-ом каждый кадр, плюс ещё накладные расходы на диспетчер.
Push становится антипаттерном, когда у вас порядок событий критичен и классический пример это AI, который получил OnEnemyVisible и OnEnemyLost в неправильном порядке из-за того, что события рассылаются в разных потоках, и теперь бот, у которого враг прямо перед носом, думает, что врага рядом нет.
Рush становится антипаттерном, когда команда использует его как способ избежать ответственности за дизайн. Это, наверное, самая частая болезнь современных push-архитектур, когда разработчик понимает, что новый функционал требует, чтобы три подсистемы согласованно изменили своё состояние, не хочет разбираться в их взаимоотношениях, заводит событие, на которое подписывает все три подсистемы, и отправляет в этом событии минимум информации, рассчитывая, что подсистемы сами «разберутся».
Через год у вас на руках паутина из событий, в которой каждое событие подписано на семнадцать других, и любая попытка её отдебажить заканчивается тем, что разработчик уходит пить кофе на тераску.
Как выбирать
Практическое правило, которое я бы рекомендовал, тут звучит так. Идите в pull...
В смысле используйте pull для горячего пути и для тех значений, которые редко меняются и легко доступны. А push используйте для ассет-стриминга, настроек, UI с реактивными биндингами, для сетевой репликации, для AI и save/load событий. Не идите в push для всего сразу только потому, что это «современно», и не идите в pull для всего сразу только потому, что это «просто», потому что чистая радикальность в этом вопросе всегда обходится дороже, чем разумный гибрид.
И последнее, push это не просто «технический выбор», это новая ответственность для тимлида. В pull-проектах достаточно следить за тем, чтобы код был аккуратно написан, а в push-проектах вам приходится дополнительно строить документацию по всем событиям, инструменты для их трассировки, политику weak-references, политику отписки в деструкторах, систему имён событий, и тренировать команду с пониманием того, как читать асинхронный код.
Это вторая инфраструктурная нагрузка поверх кода игры, и она может составлять до 15-20% общего объёма работ команды, и эти 15-20% надо заранее заложить в план, потому что иначе они всё равно появятся, просто появятся за счёт чего-то другого, обычно за счёт качества игры на релизе.
Сама по себе же ось push/pull это, пожалуй, тот рычаг, который чаще всего недооценивают на старте проекта, и который потом становится одной из самых дорогих в исправлении ошибок архитектуры, если выбор был сделан вслепую.
PULL PUSH
+------------------------------+ +----------------------------------+
| потребитель | | источник |
| каждый раз сам идёт за | | при изменении значения сам |
| значением: | | оповещает подписчиков: |
| v = get_setting("...") | | OnSettingChanged(name, old, new)|
| m = load_model("...") | | OnAssetReady(path, handle) |
| hp = actor->GetHP() | | OnHealthChanged(old, new) |
+------------------------------+ +----------------------------------+
цена цена
+------------------------------+ +----------------------------------+
| дёшево в инфраструктуре | | дорого в инфраструктуре: |
| всё расходится по коду | | реестр подписок, отписка в смерти|
| трудно реагировать на | | возможны гонки, порядок, |
| изменение и согласовывать | | сложная отладка |
| несколько потребителей | | зато система без вас знает, где |
| | | что искать и кого уведомить |
+------------------------------+ +----------------------------------+Неправильные скриптовые языки

Программисты делают НЕправильные скриптовые системы, потому что ориентируют их на программистов, в результате только программисты и могут её использовать. Это диагноз индустриальной болезни, которая до сих пор регулярно случается в новых студиях, у которых не было опыта работы с большими проектами, и причина её всегда одна и та же.
Программист считает, что скриптовый язык это просто более простой и динамичный C++, и реализует его так, как ему было бы удобно самому, не задумываясь, что у скриптового языка целевая аудитория совершенно другая, и что для этой аудитории «удобно» означает совсем не то, что для программиста.
Идеальный скрипт для непрограммистов и не скрипт вовсе. Это значит, что если вы хотите дать геймдизайнеру или техническому художнику возможность что-то менять в игре, не зовя программиста, то ваша задача не выдать ему IDE с подсветкой синтаксиса и руководство на четыреста страниц, а спрятать программирование за интерфейсом, который выглядит как нечто принципиально иное.
Как таблица, как диаграмма состояний, как тайм-лайн, как нодовый граф, как опросник, как редактор материала с ползунками, как угодно, кроме текста с фигурными скобками. И индустрия, пройдя долгий путь от Quake Console до Blueprints и Niagara, на собственных шишках выучила этот урок и продолжает его выучивать, потому что каждое новое поколение разработчиков по-прежнему регулярно изобретает «удобный скрипт для геймдизайнера», который оказывается удобным только для самого программиста.
Зачем вообще нужен скриптовый слой
Прежде чем разбирать конкретные системы, стоит ответить на вопрос, ради чего вообще вся эта инфраструктура поверх основного языка движка нужна, и aruslan в лекции даёт исчерпывающе простой ответ. Скриптовая система делается ради двух вещей и обе они экономические, а не технические.
Первая это удешевление продукта, и есть только три способа сделать продукт дешевле. Меньше людей, меньше зарплаты и меньше сроки. Скрипты помогают достичь второго и третьего варианта одновременно, потому что геймдизайнер и технический художник стоят дешевле системного программиста, а итерации в скрипте без перекомпиляции движка проходят в десять раз быстрее, чем итерации в C++.
Вторая это улучшение игры, и есть только один способ сделать игру интереснее. Это дать возможность двигать ползунки тем, кто в этом понимает, и как вы понимаеет - это не программисты вовсе. Это, наверное, должно быть самой важной фразой во всей этой статье, потому что она объясняет, почему скрипты это не про экономию ресурсов, а про доступ к творческим решениям людей, которые умеют их принимать.
Из этих двух пунктов сразу следует, что скриптовый слой это не вопрос «нужен ли он», а вопрос «для кого он», и если вас на проекте один человек, то обширный data-driven не просто не нужен, а противопоказан, потому что писать инфраструктуру скриптов в одиночку и под себя это чистая трата времени, которую вы лучше потратите на саму игру.
Любой разговор о скриптах начинается с признания того, какие именно непрограммисты будут с ними работать, сколько их будет, какого они уровня, и что именно вы хотите им разрешить менять без вашего участия.
Ответ на эти вопросы практически полностью определяет, какую скриптовую систему вам строить, и пытаться сделать «универсальную скриптовую систему» без этих ответов такая же ошибка, что и строить «универсальный движок» без понимания того, какие игры на нём будут делать.
Quake Console и cfg-файлы
Самая первая и до сих пор живущая форма скриптового слоя в индустрии это Quake Console, она формально вообще не скрипт, она просто набор cvar-ов и команд, которые игрок или скриптер может передавать и движок будет их выполнять при загрузке или в рантайме.
Кармак в 1996 году сделал её не от великой архитектурной мысли, а чисто для прикладных нужд, потому что Quake надо было настраивать на множестве разных машин с разным железом, и зашитые в exe значения не давали ни малейшего шанса геймдизайнеру и QA подкрутить параметры под конкретную сцену без перекомпиляции.
Решение получилось простым до гениальности и каждая «имя плюс значение», было по сути командой для зарегистрированных функций с аргументами, и cfg-файл это последовательность вызовов с возможностью эти команды выполнять.
Удивительно, насколько долгоживущей оказалась эта простая конструкция. Quake-консоль перешла в Half-Life, потом в Source Engine, потом в Source 2, и сегодня в Counter-Strike 2 ровно тот же интерфейс с bind, alias, cvar, +attack, mp_freezetime 5, который игроки писали в 1998 году.
Тот же подход вошёл в id Tech 3, id Tech 4, id Tech 5/6/7, и в Doom Eternal вы по-прежнему можете открыть консоль через cvar в startup-параметрах. В Unreal Console команды другие, но идея та же. Этот формат настолько прижился, что появилось целое поколение игр, которые продаются именно благодаря возможности игроков настраивать всё через консоль, делая и консоль и cfg-файлы минимальным скриптовым слоем, который не требует от пользователя знать программирование, и при этом даёт ему возможность «двигать ползунки».
Парадокс в том, что многие современные движки забыли об этом уровне и сразу прыгают к чему-то сложному, а потом удивляются, чёй то геймдизайнеру Василию неудобно настраивать игру через тридцать три менюхи?
DSL для непрограммистов
Следующий уровень это специализированный язык для конкретной задачи, вроде par { loop { set_color(red); wait(1); ... } }
и идея здесь принципиально другая, чем у консоли.
Язык должен быть домен-специфичным, то есть выражать не общую вычислительную мощь, а понятную пользователю мысль на естественном для предметной области уровне. В этом случае пользователь не пишет программу, он описывает поведение, и язык за него отвечает за то, как это поведение исполнится в правильное время с правильными ресурсами.
Самый успешный, наверное, пример такого DSL в индустрии это Flash/Animate timelines. Adobe Flash изначально вообще не воспринимался как «программирование», аниматор просто рисовал ключевые кадры, расставлял их по тайм-лайну, добавлял слои и эффекты, и через несколько лет на этом фундаменте выросла практически вся 2D-веб-анимация двухтысячных, плюс прорва игр (Machinarium, Limbo, Braid, Cuphead), у которых вся 2D-анимация делалась в Flash/Animate с последующим экспортом в спрайтлисты. Никто из аниматоров не считал себя программистом, а технически они программировали движение объектов во времени.
Аналогичные DSL в нодовом или тайм-лайновом виде сегодня живут везде. Unity Animator и Mecanim позволяют собрать state machine персонажа из состояний и переходов, и аниматор может сделать всю боевую анимацию персонажа, не написав ни строчки на C#.
Unreal AnimGraph и Animation Blueprint делают то же самое в нодах, плюс позволяют наложить Inverse Kinematics, Aim Offset, Layered Blend Per Bone.
Spine и DragonBones это специализированные DSL для 2D-скелетной анимации, в которых аниматор работает с цепочками костей, мешами и атласами и опять же не пишет программ.
UE UMG Animations позволяют дизайнеру UI собрать сложную анимацию виджета с tween-ами без программирования. И во всех этих случаях работа в DSL воспринимается не как «программирование», а как «творчество», и это то, что отличает правильный DSL от его псевдо-варианта, в котором пользователю формально дали возможность что-то менять, но через текстовый редактор и со ссылкой на документацию.
Универсальные языки как скриптовый слой
Когда возможностей DSL не хватает, потому что вам нужны не описания движения, а полноценная логика с условиями, переменными, циклами и сложными структурами данных, индустрия идёт к универсальным языкам, встраиваемым в движок. Это компромисс, потому что универсальный язык легко выучить программисту, но непрограммисту он по-прежнему сложен, и поэтому универсальный язык хорошо работает только для определённых задач, в первую очередь для AI и для «создания ползунков», когда программист пишет логику, а наружу торчат удобные настройки для дизайнера.
Lua это самый успешный из универсальных языков и фактический стандарт индустрии. World of Warcraft UI написан целиком на Lua, и моддерское комьюнити WoW за два десятилетия создало тысячи интерфейсных аддонов, которые невозможны были бы на любом другом языке без огромных вложений Blizzard.
Roblox Studio использует Lua как основной язык разработки игр, и через него миллионы школьников по всему миру делают игры в Roblox, и это вообще-то самая успешная скриптовая платформа в истории индустрии, превратившая Lua в массовый детский язык программирования.
Defold от King использует Lua как основной язык игровой логики. Love2D на Lua это стандарт для инди-2D на Lua. Garry's Mod дал моддерам возможность писать целые модификации Source через Lua, и из этого выросли отдельные жанры типа DarkRP, TTT, Murder.
Lua привлекателен по нескольким причинам, которых нет в других языках. Он крошечный (около 200 КБ в скомпилированном виде), что важно для встраивания. Быстрый, особенно с LuaJIT, который в некоторых задачах догоняет C и простой до примитивности, что облегчает освоение непрограммистами.
Python в индустрии был очень популярен в нулевые и стал реже встречаться сейчас, EVE Online до сих пор работает на Stackless Python, который CCP в своё время выбрали ради работы с потоками, через которые реализована вся серверная логика игры.
Stackless Python уже не используется на 100% в новой EVE: «GitHub repository has been archived since February 2025, and the project has been officially discontinued». По состоянию на 2026 год EVE мигрирует на обычный Python.
Civilization IV на Pythonx с возможностью моддинга через скрипты привёл к появлению огромной мод-сцены, а Battlefield 2 использовал Python для скриптов миссий; И это, по сути, второй после Lua самый популярный встроенный язык в индустрии.
C# как скрипт прижился прежде всего в Unity, и формально C# в Unity это не скрипт в классическом смысле, потому что он компилируется в IL и исполняется на Mono или IL2CPP, но по своей роли в проекте он именно скрипт, потому что обычный разработчик в Unity не пишет движок, он пишет логику игры поверх C# API.
Это сильно отличается от Lua/Python, потому что C# гораздо строже типизирован, имеет более тяжёлый рантайм, но взамен даёт хороший редактор свойств, удобную сериализацию, IntelliSense и полноценную IDE-разработку. Unity сделали ставку именно на это и оказались правы.
JavaScript/TypeScript живёт как игровой скрипт в нескольких заметных проектах и Defold даёт выбор между Lua и JS, Cocos Creator использует TypeScript как основной язык, Babylon.js и Phaser это полноценные web-движки на JS. JS не самый удобный язык для встраивания, и его рантайм тяжелее Lua, но взамен он даёт огромную экосистему библиотек и базу разработчиков, что для прикладных задач часто важнее технических качеств.
UnrealScript как антипример
Стоит на нём остановиться, потому что это редкий пример того, как очень большая компания публично признала ошибку в скриптовой системе и сменила её на другую. UnrealScript, придуманный еще в Unreal Engine 1, был похож на Java и C++ с собственным компилятором, и в Unreal Tournament 1999 года он работал замечательно, потому что геймплейные программисты Epic знали его наизусть и писали в нём всё.
Но через десять лет, в Unreal Engine 3, оказалось, что UnrealScript принципиально неудобен для геймдизайнера, не давал нормальной производительности для общего кода, имел свой собственный набор багов, и при этом был достаточно мощным, чтобы быть «настоящим программированием», то есть отпугивал именно тех, для кого вроде как был сделан. UnrealScript это был жёсткий геморрой, который написали программисты для непрограммистов, но сами писать на нем не хотели, предпочитая писать на плюсах.
Epic в Unreal Engine 4 открыто признали этот ошибкой и полностью убрали UnrealScript и заменив его на C++ для программистов и Blueprints для непрограммистов. C++ остался для общего кода и сложных систем, а Blueprints остался для геймдизайнеров, где надо собирать логику из нод в графе, может проиграть пошагово, может вызвать функции на C++ и расширять собственные ноды.
Blueprints писались непрограммистами для непрограммистов, поэтому и стали едва ли не главным архитектурным преимуществом UE4/UE5 над конкурентами в плане скорости итераций геймдизайнера, и в довольно многих проектах сегодня Blueprints это единственный язык, на котором написана вся игровая логика, без единой строчки C++.
Blueprints используют ту же виртуальную машину, что и UnrealScript.
Визуальные DSL
Параллельно с Blueprints в индустрии расцвели другие визуальные DSL для конкретных задач, и их перечисление это, по сути, карта успехов в реализации идеи «скрипт, который не скрипт». Niagara в Unreal для систем частиц и VFX, VFX Graph в Unity, Shader Graph в Unity и Material Editor в Unreal для шейдеров, Houdini VOPs для процедурной геометрии, Substance Designer для процедурных текстур, PCG (Procedural Content Generation) в UE5 для процедурных уровней, Behavior Trees для AI, Sequencer в Unreal и Timeline в Unity для кинематографии, Animation Graphs для скелетной анимации, Bolt и PlayMaker как сторонние визуальные скриптинг-системы для Unity.
Каждая из них это узкоспециализированный DSL, который выглядит как инструмент для творчества, а не как программирование, и каждая из них расширяет круг людей, которые могут с её помощью что-то полезное в игре сделать.
Самое интересное наблюдение тут, что со временем визуальные DSL имеют свойство постепенно дрейфовать в сторону «полноценного программирования», и почти каждый из перечисленных выше получил в какой-то момент возможность вставлять туда «Custom Node» с произвольным кодом, либо «Function Library» с пользовательскими функциями, либо подключать внешние C++ или HLSL модули.
Это происходит потому, что границы возможностей DSL постепенно становятся видны его опытным пользователям, и они начинают просить «маленькую возможность» вписать туда что-то более общее. И тут стоит сказать, что в этот момент DSL начинает превращаться в полноценный язык программирования, и относиться к нему уже надо как к языку, т.е. документировать, версионировать, контролировать обратную совместимость и делать средства отладки. Без этой работы DSL быстро превращается в неподдерживаемое болото из узлов, в которые когда-то кто-то впихнул произвольный код, и через два года никто уже не помнит, зачем.
Где скриптовые языки ломаются
Главных проблем с встроенными языками много, но некоторые стоят острее других. Первая это производительность и любой скрипт работает заведомо медленнее нативного кода, что означает невозможность исполнять там скриптовую логику.
Lua с LuaJIT даёт неплохие цифры, но даже она в типичной игре заметно медленнее эквивалентного C++, и нагружать ей физику или low-level рендер совершенно бессмысленно. Поэтому движки чётко делят код на «слой на C++» и «слой на скриптах», и стараются не позволять скриптам залезать в первый слой. Грамотные API скриптов всегда сделаны так, чтобы геймдизайнер мог одним вызовом нативной функции сделать большую работу, а не сотней вызовов делать ту же работу в скрипте.
Другая проблема это отладка. Скриптовый код по умолчанию хуже отлаживается, чем нативный, потому что у него свой стек, своя система профилирования, и не все IDE умеют с ним работать. Хорошие скриптовые системы решают это собственными отладчиками: Visual Studio Code + DAP для Lua, PyCharm для Python, Visual Studio + Rider для C# в Unity, встроенный debugger Blueprints в Unreal прямо в визуальном графе. Без таких инструментов скриптовый код быстро превращается в «магию», в которую никто не лезет, и обычно сваливается обратно на программистов для починки, что съедает всю экономию от введения скриптов.
Последняя проблема это версионирование и обратная совместимость. Когда вы выкатываете новую версию движка с обновлённым API скриптов, все скрипты, написанные под старую версию, могут не запуститься или работать иначе, и это означает дополнительную кооординацию между разработчиками движка и геймдизайнерами с серьёзными организационными издержками.
Как выбирать
Практическое правило тут простое: начинайте с консоли и cvar, всегда, какой бы движок вы ни писали, потому что это бесплатно и сразу даёт геймдизайнеру и QA возможность подкручивать параметры в рантайме. Потом добавьте специализированные DSL для тех задач, для которых они есть в индустрии: анимация, материалы, частицы, AI, UI, кинематика, нет смысла изобретать собственное, возьмите готовое решение из движка или из стандартного набора инструментов.
Встройте универсальный язык для тех задач, для которых DSL недостаточно вроде AI с произвольной логикой, миссии с длинными сюжетными скриптами, UI с состоянием, моддерская инфраструктура. Lua здесь практически бесплатный выбор по умолчанию, если у вас нет других сильных причин брать не его. И только если вы пишете собственный движок и у вас есть на это бюджет, рассматривайте возможность сделать собственный язык, потому что собственный язык это огромная и долгая инвестиция, которая окупается только на проектах масштаба Naughty Dog GOAL или Epic Blueprints, и если вас вас не такая компания, не пытайтесь.
Очень часто в плохо организованных проектах скрипт превращается в место, куда сваливается весь код, который программистам было лень нормально проектировать, и через год скриптовый слой содержит тысячи строк, в которых перемешаны бизнес-логика игры, низкоуровневые оптимизации и хаки. В таких случаях скрипт перестаёт быть инструментом для непрограммистов и становится теневой архитектурой, в которой не работают ни программистские, ни геймдизайнерские принципы, и единственный способ её привести в порядок это либо вынести часть в нативный код, либо переписать с нуля. Чтобы этого не случилось, тимлиду надо регулярно смотреть, что лежит в скриптовом слое, и сразу выносить оттуда то, что туда не должно было попасть.
аудитория инструмент пример
-----------------------------------------------------------------
тех-юзер cfg + console Quake bind/cvar
QA, инвайт-тестер cfg + .ini + хоткеи Skyrim, Source mp_*
геймдизайнер специализированный DSL Behavior Trees,
AnimGraph, Sequencer
тех-художник визуальный DSL Material Editor,
Niagara, Shader Graph
геймплейный универсальный язык + Blueprints, Lua,
программист bindings C# in Unity
моддер песочница над универс.яз. Luau in Roblox,
Lua in Garry's Mod
движковый прог. нативный код C++, Rust, чистый CGoF: что сейчас используют

GoF, то есть книга «Design Patterns: Elements of Reusable Object-Oriented Software» Гаммы, Хелма, Джонсона и Влиссидеса, вышла в 1994 году и на долгие годы стала OOП мозга программирования и я отношусь к ней с большим уважением, потому как сам на ней вырос. Но книга писалась тридцать лет назад в очень конкретном контексте, который сильно отличается от современного, и многие её рекомендации либо устарели вместе с языками, которые их породили, либо оказались специфичны для одного семейства языков и не очень применимы в других.
Главная критика GoF со стороны игровых разработчиков, что в ней очень мало про реальные паттерны. Там очень много про конкретную реализацию и защититу уже существующего кода, то есть GoF на самом деле не книга про паттерны проектирования, а книга про реализацию защиты от изменений в стиле Java и раннего C++. И читать её надо именно с этим пониманием, иначе вы скопируете в свой код вещи, которые в современном игровом C++ либо не нужны, либо реализуются принципиально иначе.
Этот сдвиг хорошо виден по тому, как изменились сами языки. GoF писался в эпоху, когда у программистов инстанцирование в стилеnew ClassName() было рассыпано по всему коду, шаблоны в C++ все еще были экзотикой, а лямбды отсутствовали как класс. MPL и DSEL были уделом одного-двух экспертов, а вариативные шаблоны и std::variant это вообще были разказы в научно-фантастических журналах.
В таком языке многие паттерны GoF действительно были инженерным прорывом, потому что они показывали, как через комбинацию виртуальных методов и наследования получить хоть какую-то гибкость. В современном C++23, Rust, C# и Swift многое из того, что в GoF было сложным паттерном с пятью классами, реализуется одной лямбдой или одним std::variant, и это надо понимать, чтобы не плодить ненужную инфраструктуру ради ненужной инфаструктуры.
Поэтому GoF-паттерны «нужно знать», местами нужно знать с пониманием контекста и можно не использовать, и если вы заглянете в код современных движков, то люди уже это путь прошли осознали и в болшинстве случаев приняли.
Singleton: и не паттерн вообще
Singleton это паттерн, который декларирует, что в системе должен быть ровно один экземпляр некоторого класса, и предоставляет глобальную точку доступа к нему. Звучит это солидно, пока вы не попробуете на нём собрать что-то реально большое и тестируемое, тогда выясняется, что Singleton это не «один экземпляр», а просто глобальная переменная с красивой синтаксической оберткой, и расплачиваться за неё придётся всеми теми же грехами, за которые в индустрии в конце девяностых клеймили глобалки.
В играх двухтысячных Singleton был эпидемией и ы idTech жил знаменитый engine_t engine; как глобальный объект движка, к которому можно было обратиться откуда угодно, и Кармак позже в своих публичных постах признавал, что это была не лучшая идея.
В Source Engine были g_pStudioRender, g_pPhysicsCollision, g_pSoundEmitterSystem, и хотя Valve формально оформляли это как сервис-провайдеры, на практике через них работала ровно та же singleton-логика «один указатель на всех».
В CryEngine глобальный gEnv до сих пор является одним из главных архитектурных приемов, а в Unity все также пор живёт GameObject.Find и FindObjectOfType как «отложенный singleton», и каждый проект на Unity рано или поздно заводит собственного GameManager, у которого public static GameManager Instance.
Главные проблемы Singleton это скрытые зависимости. Когда ваш код где-то в недрах зовёт Engine::Get().GetRenderer().DrawSprite(), по сигнатуре функции совершенно непонятно, что она зависит от рендера, от Engine и от их жизненного цикла, и при попытке вытащить эту функцию в отдельный модуль или протестировать её в изоляции вы обнаружите, что она тянет за собой половину движка.
Скрытые зависимости приодят к пробмлеме с порядоком инициализации. Оказывается Singleton'ы зовут друг друга при инициализации в порядке, который зависит от порядка статической инициализации в C++. Это даёт классический static initialization order fiasco, при котором ваш движок ведёт себя по-разному в зависимости от того, какая версия компилятора собирала бинарник.
И напоследок это все выливается в непригодность для unit-тестов, потому что вы не можете заменить Singleton на мок, изза того, что он сам себя инстанцирует и сам контролирует доступ.
Современная индустрия эту болезнь, в общем-то, переболела, и сегодня большинство движков используют гибрид Service Locator с явным временем жизни. Unreal Subsystems (мы про них уже говорили в разделе про подсистемы) это явное признание того, что Singleton как идея нужен, но реализован он должен быть инфраструктурой движка с управляемым жизненным циклом, а не статической переменной в C++.
Unity Dependency Injection через Zenject, Extenject, VContainer делает то же самое и вместо глобальных переменных у вас контейнер, в который вы регистрируете сервисы, и каждый код получает их через конструктор или поле.
Bevy ECS Resources в Rust это вообще другой подход и «singleton» теперь просто компонент мира, к которому система запрашивает доступ через Res<T> и ResMut<T>, и Rust на уровне borrow checker гарантирует, что одновременно к нему имеет доступ либо один мутабельный референс, либо несколько имутабельных. Все три решения принципиально лучше Singleton именно потому, что они делают зависимости явными и возвращают контроль над временем жизни программисту.
Поэтому практический совет такой, если вы видите в коде static T& Instance(), не убегайте сразу с криком, но задайте себе вопрос «почему этот объект не может быть просто полем в каком-то родителе, явно созданном при старте проекта». В 95% случаев он может быть, и Singleton там оказался от лени или от неопытности автора. В оставшихся 5% случаев Singleton действительно оправдан, например, для логгера, который должен быть доступен из любой точки кода даже до полной инициализации движка, или для аллокатора, который тоже должен жить до самого финала программы, и эти случаи стоит документировать прямо в коде, чтобы следующий человек понимал, почему здесь Singleton.
Command: вытеснен лямбдами
Command в GoF выглядел достижением, который превращал логику в объект. Вы оборачиваете вызов функции в объект, который можно сохранить, передать, отменить и переисполнить, и этот объект играет роль команды в стиле «выполни вот это вот тогда, и я заранее уже знаю, как это сделать».
В 1994 году это было "прыжком для всего человечества", потому что в старом Java и старом C++ передать функцию как объект первого класса было невозможно, а Command был способом это сделать и сделать красиво.
Сегодня в C++ Command в большинстве случаев легко заменяется std::function или лямбдой, потому что вам нужен ровно тот же эффект «упаковать вызов и сохранить его на потом», и для этого совершенно не нужны абстрактный базовый класс с виртуальным Execute() и фабрика конкретных команд.
C# даёт то же самое через Action и Func<T>. Rust через Fn, FnMut, FnOnce. Lua через first-class functions. И во всех этих языках, если вы видите код, написанный в духе GoF Command с базовым классом и наследниками, скорее всего, он был написан до того, как в языке появились нормальные функторы, и теперь его можно безопасно переписать и сделать в десять раз короче.
И когда вы 90% команд замените на функторы, то окажется что можно было жить и без них. Но есть пара мест, где Command в современном виде живёт и здравствует и будет делать это еще очень долго, и это undo/redo системы в редакторах. Когда вы работаете в Unity, Unreal, Godot, Blender или Houdini и нажимаете Ctrl+Z, под капотом работает Command-pattern в его исходном GoF-варианте, потому что каждое действие пользователя оборачивается в объект, который умеет себя выполнять и откатывать, и стек этих объектов это и есть undo-история.
То же самое в AI-планировщиках типа GOAP, потому actions в плане GOAP это именно чистые комманды, где у каждого action есть Execute, IsApplicable, и предусловия с эффектами, и планировщик собирает из них последовательность, которая ведёт к цели. В replay-системах и сложных AI-планировщиках действия это правильное место для применения Command-объектов, которые записываются на диск, изменяться, сохраняться или быть переисполнены в обратном порядке.
Поэтому используйте лямбды и std::function там, где хотите «упаковать вызов на потом», но когда вам нужны отмена, повторение, сериализация на диск, передача по сети как объекта, идите в полноценный GoF Command с базовым классом, потому что лямбды этого всего не умеют.
Visitor: жёсткая привязка к 90-м
Visitor в GoF это паттерн, который разделяет операции над набором объектов разных типов от самих объектов. Вы пишете базовый класс Visitor с методами Visit(ConcreteA*), Visit(ConcreteB*), Visit(ConcreteC*), и каждый ваш объект имеет метод Accept(Visitor*), в котором делает вызов в нужный метод визитора. Эта схема позволяет добавлять новые операции к набору типов, не меняя сами типы.
Visitor это мощная идиома, жёстко завязанная на конкретные языки с обратной связью. В Java и старом C++ Visitor был рабочим инструментом, потому что иначе добавить новую операцию к закрытому набору типов было либо невозможно, либо очень криво.
В современном C++ для этой задачи есть std::variant плюс std::visit, в C# есть pattern matching через switch и records, в Rust есть match плюс enum с алгебраическими типами, в Swift и Kotlin тоже наверняка что-то есть, но я эти языки не знаю. Все эти возможности дают то же самое, что Visitor, в десять раз короче и без необходимости плодить инфраструктуру обратных вызовов.
Но сама идея Visitor никуда не делась, и в современных движках она проявляется разных местах, просто названа по-другому. Например идея в ECS с World.ForEach<A, B> это, по сути, тот же Visitor наизнанку, когда вы декларируете набор типов компонентов, которые хотите посетить, и движок проходит по всем сущностям, у которых эти компоненты есть, и вызывает вашу лямбду. Технически это не классический Visitor, потому что обратной связи через виртуальный метод нет, но логически это та же самая идея «отделить операцию от типов, к которым она применяется».
Или компиляторы шейдеров и AST-преобразования, когда DXC или slang компилируют HLSL в SPIR-V, это классические Visitor-проход по дереву AST. То же самое в Blueprint compiler в UE, который проходит по узлам графа и генерирует команды. Даже кодогенерация Unreal Header Tool, который читает заголовочные файлы C++ с UCLASS, UPROPERTY и UFUNCTION и генерирует под них код обертки, тоже использует Visitor для обхода файлов C++.
Или обход сцены для рендера, когда внутри идёт Visitor-обход всех Renderer-компонентов с применением операции «отрисовать» и собирает их в очередь для рендера. Это Visitor, просто никто не называет это словом «Visitor», потому что слово стало восприниматься как академическое.
Observer: в GoF неудачен
Observer в GoF это паттерн, в котором один объект (Subject) держит список наблюдателей (Observer), и при изменении состояния оповещает их всех через вызов виртуального метода Update(). В GoF это описано очень низкоуровнево, потому что классический Observer в стиле GoF требует базовых классов с виртуальными методами, ручного управления списком подписчиков, ручной отписки в деструкторе и так далее.
В современных движках Observer живёт в виде slot-signal библиотек, которые скрывают всю эту инфраструктуру за более высокоуровневым API. Qt signal-slot это, наверное, самая известная реализация, и она использовалась во многих игровых редакторах, включая ранние Unreal Editor и Frostbite editor. boost::signals2 даёт то же самое в чистом C++ с автоматической отпиской через scoped_connection. MulticastDelegate в Unreal через DECLARE_MULTICAST_DELEGATE_* это игровая реализация Observer с поддержкой UObjectи автоматической отписки при уничтожении подписчика. UnityEvent в Unity делает то же самое для C#-мира. Signals в Godot интегрированы в саму систему нод и используются буквально на каждой кнопке UI.
Но не думайте, что паттерн неудачный. Из этой идеи выросло все реактивное программирование и доросло до полноценной парадигмы в виде UniRx, UniTask, R3, которые дают объекты, которые можно фильтровать, преобразовывать и комбинировать чтобы получать нужные события.
RxSwift, RxKotlin, RxJS делают то же самое в своих экосистемах. А Noesis GUI (используется в The Witcher 3, Cyberpunk 2077, Baldur's Gate 3) вырос в отдельное направление софта и применяет Observer для реактивных UI.
То есть Observer как идея совершенно не устарел, наоборот, он стал одним из фундаментальных архитектурных приёмов современных движков. Что устарело, так это его исходная GoF-реализация с виртуальными методами и ручным управлением подписками. Если вы сегодня пишете Observer-подобную систему, не берите её из GoF, берите её из Unreal или Qt, или из современных delegate-библиотек, или из реактивных фреймворков, и относитесь к Observer как к идее, а не как к конкретному рецепту.
Memento: в играх живёт и сейчас
Memento в GoF предлагает паттерн, в котором один объект может сохранить своё состояние в специальный объект-снимок и восстановиться из него потом, не нарушая инкапсуляции. Описание Memento в GoF скорее литературное, чем техническое, и реальные реализации этого паттерна в играх выходят далеко за рамки того, что GoF описывает.
В играх Memento живёт в нескольких ключевых местах вроде Save/Load системы, когда при сохранении игры все сущности игрового мира должны сериализоваться в снимок, и при загрузке восстановиться из него, и это именно Memento, просто очень развитый.
Второе место обитания это Replay-системы в RTS, когда игры (StarCraft II, Age of Empires IV, Company of Heroes 3, Civilization VI) сохраняют не каждый кадр состояния всей игры, а детерминистическую последовательность действий игроков плюс начальное состояние мира, и реплей это просто переисполнение этой последовательности с начальным снимком.
Это очень красивая реализация паттерна, в которой снимок это не вся история, а только её начальное состояние плюс входной поток, поэтому реплей-файлы получаются крошечными даже для многочасовых матчей.
Или в отдельных шутерах (Call of Duty, Modern Warfare, Battlefield), где движок постоянно держит в кольцевом буфере последние пять-десять секунд состояния мира, и при смерти игрока показывает эту запись, переключая камеру на убийцу. Реализация в каждой игре своя, но идея одна и та же.
Еще Memento используется в сетевых файтингах (Tekken 8, Street Fighter 6, Mortal Kombat 1), когда каждый клиент держит в памяти снимки состояния игры за последние N кадров, а при получении ввода соперника откатывает состояние на нужный кадр в прошлом, переисполняет с момента отката с правильным вводом, и догоняет до текущего кадра.
Это требует, чтобы вся игра была детерминистической и полностью сериализуемой в снимок за миллисекунды, и реализация такого Memento в современном файтинге это сложнейшая инженерная задача, в которую вложены тысячи человеко-часов. Undo/Redo в редакторах мы уже разбирали в разделе Command, но технически это тоже Memento, потому что undo-стек хранит снимки состояния «до» и «после» каждого действия.
Facade: подход, а не паттерн
Facade в GoF это паттерн, в котором сложная подсистема скрывается за упрощённым интерфейсом-фасадом, через который её только и используют. Facade описывает не конкретную структуру кода, а архитектурное решение «давайте упрячем эту сложность за более простой интерфейс», и оно никак специально не реализуется, оно просто появляется как побочный эффект хорошего проектирования.
В играх Facade встречается на каждом шагу, но не называется этим словом. Граница между Engine и Game в Unreal, Unity и любом другом движке это Facade поверх движкового кода. API в Steamworks SDK это Facade поверх сложных серверных взаимодействий со Steam. API Wwise или FMOD это Facade поверх сложной аудио-инфраструктуры. API физического движка (PhysX, Havok, Box2D, Bullet, Jolt) это Facade поверх миллиона строк физических вычислений. API графического движка (Vulkan, DX12, Metal) это Facade поверх драйвера видеокарты, который сам по себе Facade поверх железа.
Это не паттерн в смысле «возьмите вот эту конкретную структуру и используйте её», это общая идея «прячьте сложность за упрощённым интерфейсом», которая лежит в основе всего хорошего проектирования API. Поэтому Facade в индустрии живёт повсеместно, но обсуждать его как отдельный паттерн в общем-то не нужно, потому что любой нормальный модуль и так должен иметь упрощённый внешний интерфейс.
Что надо знать наизусть

Из GoF нужно реально хорошо помнить и знать структурные и порождающие паттерны, потому что их вы будете реализовывать на каждом проекте, и они никуда не делись с появлением современных языков.
Abstract Factory живёт в VFS-провайдерах, где для каждого формата файла есть своя фабрика, в рендер-бэкендах (IRenderBackendFactory создаёт DX12Backend, VulkanBackend, MetalBackend), в сетевых транспортах (INetworkTransportFactoryсоздаётTCPTransport, UDPTransport, WebRTCTransport)и в AI-планировщиках (фабрика создаёт GOAPPlanner,BehaviorTreePlanner, TNPlanner.
Это совершенно живой паттерн, который пригождается каждый раз, когда у вас есть семейство связанных объектов с разными реализациями.
Builder живёт в системах создания персонажей (CharacterBuilder собирает героя из расы, класса, экипировки, навыков), в уровень-генераторах (DungeonBuilder собирает уровень из комнат, коридоров, ловушек), в mesh-builder'ах для процедурной геометрии, в shader-builder'ах, в command list builder'ах для рендера.
Prototype живёт в системах префабов (Instantiate(prefab) в Unity, SpawnActor(class, transform) в Unreal, instance в Godot), в операциях для копирования объектов, в процедурной генерации (берём прототип врага и параметризуем его). Это один из самых базовых и часто используемых паттернов в играх.
Adapter живёт в обёртках над сторонними библиотеками, когда вы интегрируете внешний SDK с одним API в свой движок с другим API, и пишете адаптер. В input-системах, которые адаптируют разные форматы геймпадов (XInputAdapter, DualShockAdapter, SteamInputAdapter) к единому интерфейсу IGamepad. В legacy-обёртках, когда новый код движка работает с новым API, а старый код через адаптер продолжает работать с устаревшим API.
Bridge в чистом виде встречается редко, но его идея «разделить абстракцию и реализацию» лежит в основе любого хорошего API. Render Hardware Interface в Unreal (Engine vs RHI vs RHI-implementation для каждого API) это именно Bridge. Audio backend abstraction в Wwise (Sound Engine vs platform-specific audio backend) тоже.
Composite это один из самых популярных паттернов в играх, потому что любая иерархическая структура это Composite. Scene Graph в Unity (Transform.parent + children), AActor + USceneComponent в Unreal, Node hierarchy в Godot, все это Composite. Behavior Trees это Composite поверх узлов поведения. UI hierarchies через виджеты-контейнеры. Render Graph через group-of-passes.
Proxy живёт в отложеном выполнении и загрузке (AssetProxy загружает реальный ассет только при первом обращении), в сетевых клиентах (RemoteServiceProxy представляет сервер локально), в кэшах (CachingProxy запоминает результаты обращений).
Flyweight один из самых характерных для игр паттернов, потому что в играх постоянно есть тысячи однотипных объектов с большим количеством общих данных. Particle System через Flyweight держит общий описатель эмиттера и тысячи лёгких частиц, разделяющих его данные. Mesh instancing в GPU это Flyweight: одна геометрия и тысячи трансформаций.
Если попытаться сформулировать практический подход к GoF в современном игровом C++, он выглядит примерно так. Структурные и порождающие паттерны (Factory, Builder, Prototype, Adapter, Bridge, Composite, Decorator, Proxy, Flyweight) надо знать наизусть, потому что они никуда не делись и помогают каждый день.
Поведенческие паттерны (Command, Observer, Strategy, Visitor, Memento, State, Mediator, Iterator, Chain of Responsibility) надо знать как работают, но не привязываться к реализации, потому что современные языки дают для каждого из них куда более изящные средства исполенения, чем GoF мог себе позволить.
Singleton - пора бы уже закопать стюардессу... А Facade поймите как принцип, а не как паттерн.
GoF-паттерны полезно знать блоками, то есть Factory + Builder + Prototype это блок «создание объектов», и о нём имеет смысл думать как о цельной задаче как мы вообще создаём вещи в этой игре, а не как о трёх отдельных паттернах.
Adapter + Bridge + Facade это блок «адаптация API», и о нём имеет смысл думать как о вопросе как мы изолируем разные части системы друг от друга.
Observer + Mediator + Chain of Responsibility это блок «коммуникация между объектами», и так далее. Когда вы думаете блоками, вы видите архитектуру, а когда вы думаете отдельными паттернами, вы видите только детали реализации, но сама архитектура от вас ускользает.
После тридцати лет GoF стал историческим документом эпохи раннего СССР, т.е. OOP, и относиться к нему надо так же, как к старой инженерной литературе. Многое из того, что там написано, актуально и сегодня, но многое требует пересмотра в свете развилия языков и индустрии.
Программист, который читал GoF в 2026 году и пытается воспроизвести её рецепты буквально, выглядит примерно как программист в 1994 году, который пытается следовать книгам по структурному программированию из 1974 года. Вы конечно получите рабочий, но это будет архаичный код, в котором половина усилий потрачена на то, что в современном языке решается одной строчкой.
А вот программист, который читал GoF и взял оттуда главное, то есть идею называть и обсуждать паттерны вообще, получает в руки словарь для разговора с техлидом, который окупается каждый раз, когда вам объяснить, что и зачем вы сделали.
Что в итоге читать
С момента появления GoF вышло очень много полезных материалов, если вы оказались на старте карьеры или просто решили закрыть пробелы в архитектуре, то ниже будет список литературы, которую я сам читал и могу посоветовать.
POSA (Pattern Oriented Software Architecture). Это самая важная книга, потому что POSA это не GoF, в ней действительно живут реальные архитектурные паттерны того уровня, который мы обсуждали выше: Layers, Pipes and Filters, Blackboard, Microkernel, Broker, Reflection. Том I это базовые архитектурные паттерны общего назначения, и без него я бы вообще не считал, что человек закончил обучение архитектуре софта.
GoF (Design Patterns) Гаммы, Хелма, Джонсона и Влиссидеса. Я про неё уже подробно рассказал, знать надо, читать можно, структурные и порождающие хорошо если запомните наизусть, остальное принимать дозированно с пониманием исторического контекста. Не Священное Писание, но исторический документ времен мамонтов.
Booch, Object-Oriented Analysis and Design with Applications. Книга устаревшая по примерам (С++ из 90-х, Smalltalk, Ada), но методологически очень здравая, и там автор даёт хороший понятийный аппарат для анализа предметной области. Сегодня это, как Роджер Желязны с его Янтарным Циклом, дико интересно, но скорее, для общего развития, чем для прикладной работы.
Фаулер, Refactoring. Эту книгу читать обязательно, и читать в актуальном втором издании (2018) или позже если есть, потому что в нём примеры на JavaScript и более современные. Главная польза не в рефакторинге как таковом, а в том, что Фаулер даёт словарь для разговора о неудобном коде: extract method, inline variable, replace conditional with polymorphism. Когда у вас в команде это становится общим словарём, скорость code review вырастает в разы.
Lakos, Large Scale C++ Software Design. Это обязательное чтение для любого C++ разработчика, и Lakos в своё время описал, как структурировать большие C++ проекты так, чтобы они компилировались и линковались за разумное время. Его рекомендации по physical design, dependency hierarchies, pImpl pattern, forward declarations до сих пор спасают проекты от того, чтобы развалиться под собственным весом.
«Game Programming Patterns» Роберта Найстрема (доступна бесплатно на gameprogrammingpatterns.com). Это, по сути, игровой ремейк GoF, и автор явно ставил такую цель при написании книги. Он берёт каждый GoF-паттерн и плюс ещё специфичные для игр (Game Loop, Update Method, Component, Event Queue, Service Locator, Data Locality, Dirty Flag, Object Pool, Spatial Partition, Type Object, Subclass Sandbox, Bytecode), даёт игровой контекст и игровые примеры, и пишет это очень доступным языком. Если вы пишете код игр, это первая книга, которую вы должны прочитать и она доступна бесплатно онлайн.
«Game Engine Architecture» Jason Gregory. Это самая полная книга по архитектуре игрового движка, написанная человеком, который в Naughty Dog отвечал за их движок. Любое издание охватывает практически все аспекты и если вы пишете что-то похожее на игру, то это обязательная литература.
«Software Engineering at Google» это современный «Lakos» для больших проектов, и в ней детально разобрано, как строить процессы вокруг кода, тестирование, code review, dependency management, build systems, и многое из этого прямо применимо в больших игровых проектах. У меня есть стойкое ощущеие, что большинство AAA-студий в принципе никогда о ней не слышали.
GDC Vault (gdcvault.com). Огромный архив докладов с GDC, частично платный, частично бесплатный. Я бы выделил несколько ключевых докладов, которые обязательны к просмотру:
Jason Gregory про «State-Based Scripting in Uncharted 2» (2009).
Christian Gyrling про «Parallelizing the Naughty Dog Engine Using Fibers» (GDC 2015).
Damián Isla про «Handling Complexity in the Halo 2 AI» (GDC 2005).
Jeff Orkin про «Three States and a Plan: The AI of F.E.A.R.» (GDC 2006).
Yuriy O'Donnell про «FrameGraph: Extensible Rendering Architecture in Frostbite» (GDC 2017).
Tim Sweeney про «Programming the Future» в разные годы.
Michael Acton про «Data-Oriented Design and C++» (CppCon 2014, это не GDC, но обязательно).
Niklas Frykholm про «Implementing Subsystems» в архитектуре движка Bitsquid/Stingray.
YouTube-каналы, которые я бы рекомендовал:
The Cherno для базовых вещей по C++ и архитектуре движка.
Game Dev Underground для интервью с инди-разработчиками.
GDC официальный канал с бесплатными докладами.
CppCon для углублённого C++.
Блоги конкретных разработчиков. Это золотое дно, и каждый, кто читает, через год имеет совершенно другой уровень понимания.
John Carmack .plan files (исторический архив, но очень поучительный).
Casey Muratori и его серия Handmade Hero.
Mike Acton и его статьи по data-oriented design.
Sebastian Sylvan и его блог.
Aras Pranckevičius (бывший CTO Unity) и его блог.
Branimir Karadžić (автор bgfx)
Adrian Courrèges про разборы кадров в современных играх.
Wolfgang Engel (diary of a graphics programmer).
Open-source движки и их код. Это, наверное, лучшее обучение архитектуре. id Tech 1-4 (исходники доступны), Doom 3 BFG, Quake III Arena, Half-Life 1, Unreal Engine 3-5 (для разработчиков), Godot, Bevy, EnTT, flecs, bgfx, The Forge. Чтение исходников этих проектов даёт больше понимания архитектуры, чем десять учебников.
Что и в каком порядке читать сегодня
Если вы только начинаете и у вас есть год на самообразование, я бы предложил такой план:
Game Programming Patterns (Найстрем) для базового словаря и первого знакомства с паттернами в играх. 2 месяца + сделать примеры.
GoF для академической базы, можно не читать. Месяц.
POSA Volume I для реальных архитектурных паттернов. Два месяца.
Game Engine Architecture (Грегори) для общей картины движка. Три месяца, с экспериментами.
Refactoring (Фаулер) для умения работать с существующим кодом. Месяц.
Real-Time Rendering (если вы графический программист) или Real-Time Collision Detection (если физический) или POSA Volume II (если сетевой) для специализации. Два месяца.
Large-Scale C++ Software Design (Lakos) для понимания крупного C++ проекта. Два месяца, особенно если работаете в большой команде.
Designing Data-Intensive Applications (Kleppmann) для общего развития. Полтора месяца.
Peopleware (DeMarco/Lister) для понимания, что вы работаете с людьми, а не с машинами. Две недели, очень короткая.
Параллельно с этим минимум один GDC-доклад в неделю, минимум один технический блог-пост в день, и хотя бы один pull request в open-source проект в неделю. Но самое главное, никакая книга не заменит написания кода. Все эти книги становятся полезными ровно в тот момент, когда вы прочитали их после того, как столкнулись с проблемой, которую они описывают. Если вы читаете Lakos до того, как у вас компиляция большого проекта стала занимать час, его рекомендации останутся просто словами на бумаге. Если вы читаете POSA том II до того, как у вас была реальная задача с асинхронным кодом, паттерны Reactor и Proactor покажутся вам никому не нужной академической абстракцией. Поэтому читайте в проблеме, а не наперёд, и держите эту библиотеку под рукой, чтобы при появлении проблемы быстро находить нужный раздел и применять его на свежем кейсе.
TL;DR

Уфф... вы сюда добрались, ну как интересно было? Ж)
Если попытаться свести всё, что написано выше в одну страницу, к которой можно будет вернуться через год и быстро освежить главное, получится примерно следующее.
Архитектура игры это не вопрос «что круче», а какие компромиссы вы готовы заплатить за какие преимущества, и без честного ответа на этот вопрос любая «правильная» архитектура превращается в дорогостоящее украшение, ничего проекту не дающее.
Первое, что вы должны выяснить про свой проект это каков его масштаб (один человек или двести), кто будет вносить изменения (программист, дизайнер, художник, локализатор), какова частотность этих изменений (каждый день или раз в полгода), какие силы вы готовы поддерживать (cohesion, coupling, скорость итераций, скорость нативного кода, скорость онбординга новых людей, стабильность ABI для внешних разработчиков). От ответа на эти вопросы зависит примерно девять десятых архитектурных решений, которые вы потом будете принимать; никаких «общих» правильных ответов нет.
"Большие" архитектурные паттерны дают вам словарь для разговора об организации кодовой базы. Где у вас слои, где подсистемы и где метаслои и т.д. и большинство проектов это смесь всех со всеми, причём смесь осознанная, а не случайная.
Стратегии проектирования определяют, с какого конца вы кладёте кирпичики: top-down работает с сильным дизайном и большой командой, bottom-up работает с уникальной технологией и маленькой группой ядра, constant refactoring работает с прототипами и инди-командами, и переключаться между ними по фазам проекта это нормально, а не признак слабости.
Ось push vs pull определяет, кто несёт ответственность за актуальность данных. Pull дешевле в инфраструктуре и расходится по коду, push дороже, но система БЕЗ ВАС знает, где что искать. В современных играх стриминг ассетов, настройки с побочными эффектами, реактивный UI, сетевая репликация и AI-перцепция обычно push; геймплейные правила, математические утилиты, immediate-mode UI и горячий путь обычно pull; большинство движков это гибрид, в котором cvar и asset references дают вам выбор между push-подпиской и pull-чтением.
Data-driven design это не «как круче программировать», это способ ввести в проект новые специальности, прежде всего непрограммистские. Если вас в проекте один человек, обширный data-driven противопоказан; если у вас десяток геймдизайнеров и техническая художественная команда, без data-driven вы их не нагрузите работой. Уровни инфраструктуры идут от ручной сериализации os << foo через макросы SERIALIZABLE_FIELD до генерации заголовков по метаописанию, и выбор уровня прямо соответствует размеру и зрелости команды.
Скриптовые языки существуют не для программистов, а для непрограммистов, и идеальный скрипт для непрограммистов это все что угодно, но точно не скрипт. В большинстве случаев правильный ответ это либо специализированный визуальный DSL (Blueprints, Behavior Tree, Material Editor, Niagara, Sequencer), либо универсальный язык как Lua или C#, обёрнутый в удобный для непрограммистов интерфейс. Программисту-первокурснику невероятно соблазнительно сделать «удобный скриптовый язык», но удобство этого языка всегда оказывается удобством самого программиста, а не геймдизайнера, и через два года вы будете сами писать в нём весь скриптовый код, потому что геймдизайнерам в нём неудобно было неудобно два года назад, и не более удобно сейчас.
Управление ресурсами и ассетами разводит две принципиально разные вещи: asset это единица контента, resource это единица ограниченного физического ресурса (VRAM-страница, дескриптор, поток). Caching работает с identity (текстуры по имени), pooling с обезличенными ресурсами (пул пуль), prefetching и streaming заранее тащат то, что понадобится, lazy и partial acquisition тащат только нужное, leasing временно выдаёт и забирает обратно, evictor решает, кого выкидывать, coordinator поддерживает когерентность. Большинство современных open-world игр это сложная связка всех этих паттернов, и попытка обойтись простыми приёмами обычно заканчивается либо OOM, либо фризами при загрузке.
GoF полезен, структурные и порождающие паттерны (Factory, Builder, Prototype, Adapter, Bridge, Composite, Decorator, Proxy, Flyweight) знайте наизусть и применяйте регулярно. Поведенческие паттерны (Observer, Command, Strategy, State, Visitor, Memento, Chain of Responsibility, Iterator, Mediator) знайте по сути, но реализуйте через современные идиомы языка. Singleton не используйте без явной необходимости. Facade поймите как принцип, а не как паттерн. GoF это исторический документ, относитесь к нему соответственно.
Паттерны это словарь для обсуждения архитектурных компромиссов, а не каталог рецептов и не учебник по тому, «как правильно». Они полезны ровно в тот момент, когда два инженера, тимлид и геймдизайнер, или вы и вы-через-полгода, садятся обсудить, что и почему сделано в коде, и говорят на общем языке. Без этого словаря архитектурные обсуждения превращаются в импровизацию с маркером у доски на час, а со словарём в три раза короче и в десять раз продуктивнее.
И самое главное, что я хочу сказать в самом конце, после всех этих многих тысяч слов, разобранных паттернов и игровых примеров. Архитектура это не самоцель, а инструмент. Цель это игра, в которую интересно играть, и архитектура должна служить этой цели, а не наоборот. Программист, который выбирает паттерны исходя из их красоты или соответствия книгам, плохой программист, потому что он работает на свою библиотеку, а не на проект. Программист, который выбирает паттерны исходя из конкретных сил конкретного проекта и осознанно принимает компромиссы, хороший программист, и игры, которые он делает, в долгосрочной перспективе окупают всё, потому что они выходят, доезжают до релиза, нравятся игрокам и приносят команде и удовольствие, и деньги.
И именно эту мысль я не смог донести в своей книге Game++, потому что в ней я слишком увлёкся реализациями и слишком мало говорил о компромиссах, и именно поэтому я взялся за эту статью, чтобы попытаться эту ошибку исправить. Время программиста паттернов, компромиссы важнее реализаций, а архитектор, который начинает разговор с фабрик, ещё не очень архитектор. Я бы добавил от себя, что и архитектор, который заканчивает разговор без обсуждения сил и компромиссов, тоже ещё не архитектор, и эту фразу хорошо повесить над рабочим столом, потому что всенгда есть соблазн закончить пораньше. Хорошим архитектором становится тот, кто этому соблазну не поддаётся, и я очень надеюсь, что эта статья хоть немного помогла читателю в эту сторону продвинуться.
Спасибо, что дочитали. Если поправите меня где-то по фактическим деталям конкретных движков и проектов, я буду благодарен. Если возразите по сути, я буду благодарен ещё больше, потому что главная польза от такой статьи это вызвать качественный спор.
З.Ы. КДПВ взяты с замечательного сайта https://refactoring.guru/ с их разрешения и немного доработаны напильникомнанобананой.





















