
В прошлой статье я разбирал паттерны и необходимость компромиссов в реальной разработке, и там была одна мысль которую я намеренно оставил в стороне. Паттерны редко живут в одиночку, и любая реальная система это не один паттерн, а несколько, склеенных, скрученые, слепленных, и местами прибитых сбоку гвоздями, и каждый из них закрывает только часть проблемы. Менеджер ресурсов это, наверное, самый показательный пример такой склейки, потому что снаружи он обычно выглядит как пару строчек видаLoadTexture("bark.dds"), а внутри это кэш, политика дефолтов, механика восстановления после сбоя и ещё полдюжины вещей, каждая из которых прошла через пот, кровь и пиксели и осталась в архитектуре этой системы.
Если открыть любую книгу по разработке игр или игрового движка и попробовать найти определение "игровой ресурс", то получится что ресурс - это набор данных, которые были загружены или созданы с конкретными параметрами. Любые уточнения вроде «текстура», «меш», «звук» или «шейдер» здесь уже будут лишними, потому что нам важна не природа данных, а что они существуют именно определенной форме.
Понятие "определенная форма" тем не менее тоже звучит абстрактно, поэтому люди предпочитают использовать "текстуру", "меш", "звук" и т.д. Но одну и ту же текстуру wall.dds, которую можно загрузить в DXT5 со сжатием, sRGB и mip-фильтром box, а можно без сжатия, в линейном пространстве и с другим фильтром. Формально у нас был один файл на диске, но с точки зрения ресурсного менеджера теперь это два разных "ресурса", потому что их параметры различаются. Подмена одного ресурса другим в рантайме может сломать игру, потому что игра ожидает определенных данных для шейдера, которая изменилась после фильтра или определённую раскладку мипов, которой может не оказаться.
Более явный пример для шейдеров будет, когда lighting.fx, скомпилированный с дефайном SIMPLE_BUMP_MAPPING, и lighting.fx, скомпилированный с PARALLAX_BUMP_MAPPING, физически выглядят в исходниках как один файл, но дают два разных пайплайна, со своими константными буферами и со своими ожиданиями к набору текстур, а если ресурсный менеджер этого не понимает, то он либо начнёт раздавать второй вариант, когда просят первый.
С мешами история та же самая, и ship.mesh, загруженный в менеджере ресурсов, и тот же ship.mesh, лежащий в GPU это два разных объекта, у которых даже время жизни и поведение при потере устройства будут отличаться, не говоря уже о том, что первый мы можем менять, а в второй нет.
Отдельный случай это процедурные ресурсы, потому что у них в принципе нет файла на диске, и роль «источника» выполняет часто генератор шума и менеджер обязан хранить достаточно информации, чтобы после потери устройства или после смены настроек качества пересоздать ровно ту же текстуру, что была раньше, не подменяя её ни на что другое, иначе игрок увидит, как у него на глазах меняется рисунок дыма или сдвигается узор плитки на полу.
В No Man's Sky, где почти весь визуал процедурный, это решается тем, что seed планеты вместе с набором параметров генерации фактически и есть «адрес» текстур и мешей, и две консоли, запросившие один и тот же seed, обязаны получить идентичные данные, иначе у мультиплеера всё сломается.

Из этого вытекает не очень очевидная, но важная для дальнейшего рассказа, идея идентичности ресурсов, и формулируется она так, что два ресурса считаются одним и тем же, если у них совпадают и "источник" и "параметры". На этом стоят все ресурсные менеджеры современных движков, и когда в Source 2 или в Unreal вы натягиваете один и тот же материал на тысячу заборов на уровне, движок не создаёт тысячу копий, а возвращает один и тот же handle.
Из этого определения, безобидного и немного философского, дальше растёт почти всё остальное, и кэш, и группы дефолтов, и поведение при device lost, и механика горячей перезагрузки при смене настроек. Остается признать, что ресурс это пара (источник, параметры), и только её можно использовать как ключ в хеш-таблице и надо сохранять рядом с самим ресурсом на случай восстановления.
Что делает ресурсный менеджер

Если не забывать что ресурс - это пара «источник плюс параметры», то ресурсный менеджер в этой картине мира получается не просто таблицей, и есть смысл выделить его как отдельную подсистему движка, чтобы дизайнеры, который создают уровень, не думали ни про DMA, ни про потерянное устройство, ни про то, что у нас тут параметры мипфильтрации в одной группе одни, а в другой другие.
Самая первая и внешне простая обязанность этой подсистемы - это создавать новые ресурсы по запросу. Здесь обычно возникает "ловушка дизайна", потому что под «созданием» легко понимается только загрузка из файла, но на практике получается надо не только прочитать данные с диска, а еще распаковать, и если надо, и положить в подходящее место памяти, попутно запомнив, с чем именно он это сделал, потому что без этих данных не получится пережить device lost или смену настроек качества и придется загружать все заново.
Device lost
На пк явление довольно редкое в нормальных условиях и реально происходит при креше драйвера (TDR, Timeout Detection and Recovery), переключении между GPU на ноутбуках (iGPU ↔ dGPU) или смене разрешения или fullscreen/windowed, или при выходе из сна.
На консолях практически никогда не происходит в штатном режиме, но будет при смене HDR-настроек, смене разрешения вывода, на PS4/PS5 при изменениях любой опции относящейся к видеокарте.
На мобилках происходит чень часто, и это платформа где device lost уже норма жизни. Входящий звонок, переключение приложений, блокировка экрана, низкий заряд батареи, все это 99.9999% ведет к device lost.
Самое интересное начинается с процедурными ресурсами, потому что источника в виде файла у нас нет, и роль источника выполняет либо алгоритм с явными параметрами вроде NoiseTextureGenerator(128, 128, seed=42), либо более сложная штука вроде текущей геометрии уровня, и менеджер обязан запоминать его как «источник», уметь по нему пересоздать ресурс через час игры, и не путать NoiseTextureGenerator(128, 128, seed=42) с NoiseTextureGenerator(128, 128, seed=43), потому что для пользователя это два разных шума, даже если визуально они почти неотличимы.
В уже упомянутой No Man’s Sky, где никаких «текстур планет» в архивах игры нет, иначе бы размер игры улетел за пару терабайт, а есть алгоритм, который по seed и набору биом-параметров каждый раз восстанавливает один и тот же мир, и движок там вынужден жить в мире где текстур не существует в принципе (для процедурных объектов), и единственной правдой о ресурсе является цепочка генераторов и их аргументов.
Еще один интересный случай это создание "пустого" ресурса под runtime-данные. Сюда попадает всё, от render target и G-buffer до промежуточных состояний частиц или для проекции теней. Здесь уже источника как такового нет вообще, и менеджер обязан этот случай тоже обработать, потому что в реальной игре таких безымянных ресурсов получается едва ли не больше, чем загруженных с диска, и каждый из них точно так же может потеряться при сбросе устройства и должен быть пересоздан в том же формате и того же размера.
Из этой троицы и вырастает понимание, что у менеджера должна быть, по сути, одна точка входа, которая умеет принять либо путь, либо генератор, либо описание пустого буфера, и для всех трёх случаев вернуть ресурс одинаковой природы, потому что вся остальная часть движка не должна знать, откуда именно взялись данные, и должна работать с handle одинаково независимо от его происхождения.
VertexBufferManager::create(4, VertexDeclaration().texcoord(0));
TextureManager::create(new NoiseTextureGenerator(128, 128));В первом случае мы создаём пустой буфер на четыре вершины и явно говорим, как они выглядят, а во втором мы вместо пути на файл подсовываем генератор шума, и менеджер обязан этот генератор сохранить, потому что без него потом не получится восстановить ту же самую текстуру.
Cнаружи оба вызова это просто create, и программисту не приходится держать в голове три разных API на три разных случая, что и есть та самая «одна строчка на загрузку», ради которой мы готовы платить экспоненциально растущим количеством перегрузок.
Из игровых аналогов лучше всего эта смесь видна в Minecraft, где у одного и того же chunk-меша может быть и «источник на диске» в виде сохранённого региона мира, и «источник в виде алгоритма» при первой генерации новой области, и «источник в виде модификации игроком», когда чанк уже жил и его перекопали, и движок при этом обязан относиться к chunk-мешу как к дному и тому же типу ресурса, со своим временем жизни и со своими правилами выгрузки.
Иначе пришлось бы городить три параллельных подсистемы, которые делали бы одно и то же тремя разными способами. Атласы блоков и предметов там же создаются ровно по тому же принципу, и в зависимости от того, какие моды и текстурпаки подключены, итоговый атлас собирается в рантайме как процедурный ресурс с источником в виде «список текстур такой-то, разрешение такое-то, настройки фильтрации такие-то», и пока этот список не поменялся, движок имеет полное право не пересобирать атлас заново, потому что параметры не изменились и идентичность ресурса сохранилась.
Кеширование

Если первый пункт про создание ресурсов решал вопрос «как», то второй же отвечает на более болезненный вопрос «сколько раз», потому что в реальной игре один и тот же ресурс запрашивают сотнями и тысячами, и наивная реализация, в которой каждый запрос текстуры коры дерева идёт на диск, распаковывает DDS и кладёт его в новый блок VRAM, в первой же сцене с лесом сьест всю видеопамять и уйдет в OOM, не успев показать ни одного кадра.
Без системы кеширования вся идея менеджера ресурсов теряет смысл, и оказывается кэширование является не менее важной частью системы ресурсов как и умение, собственно эти ресурсы грузить. То есть умение в ответ на повторный запрос с теми же источником и параметрами вернуть тот же самый объект, который уже живёт в памяти, и сделать это так, чтобы программист об этом вообще не догадывался и продолжал писать свой LoadTexture("bark.dds") так где хочет.
Технически вся эта магия сводится к довольно скучной схеме, в которой у менеджера есть хеш-таблица, ключом в которой выступает пара из "источника и параметров", а значением слабая ссылка на уже созданный ресурс, и при каждом запросе менеджер сначала пытается этот ключ найти, и только если не нашёл, идёт создавать ресурс по-настоящему. А если нашёл, то возвращает существующий handle. Слабая ссылка нужна, потому чтое если хранить в кэше сильную ссылку, то ресурс никогда не выгрузится из памяти, даже когда последний игровой объект, который его использовал, давно умер.
Подсчет ссылок в этой схеме работает тоже нужен не простой, а с отложеным удалением, и как только последний владелец ушел, то ресурс отправляется в очередь LRU, откуда его потом выкинут, если место понадобится под что-то более актуальное.
Этот трюк с LRU особенно важен на консолях и на мобильных, где никакого виртуального адресного пространства размером с диск нет, и менеджер обязан понимать, что даже корректно собранный по ссылкам кэш в какой-то момент перерастёт доступный бюджет видеопамяти, и в этот момент надо принимать решение, кого выкинуть. И выкидывать тоже надо с умом, потому что через два кадра "горячие" текстуры попросят снова, иначе кэш превращается в антикэш, который только и делает, что перезагружает одни и те же ассеты по кругу.
Поэтому в нормальной реализации к хеш-таблице всегда прилагается какой-нибудь aging-механизм или прямо явный список «вот этот хендл давно не использовался», и Unreal Engine в своей системе стриминга текстур, держит в памяти только текстуры, до которых игра дотянулась камерой в последние секунды.
Реализация такого кеша позволяет его отключать. Глобальное отключение нужно во время разработки и при горячей перезагрузке ассетов, когда художник переэкспортировал текстуру и хочет, чтобы движок взял с диска свежую версию, а не вернул ему ту, что лежит в кэше с прошлого запуска, и в этом режиме весь кэш просто игнорируется, что конечно убивает производительность, но даёт мгновенный фидбек на правки, что для итеративной работы важнее любого FPS.
Но есть моменты, когда мы знаем, что вот эта конкретная загрузка точно одноразовая и кэшировать её бессмысленно, и здесь использовать кеш получается ну супер-дорого. Пример это экраны загрузки, текстура там живет несколько секунд и пихать её в кеш, будет сильно дороже чем просто загрузить напрямую, в обход кеша.
Из игровых примеров самым прозрачным и хорошо описанным был и остаётся id Tech 3 и его наследники, где R_FindImageFile и S_FindSound устроены по схеме "имя файла" плюс "несколько флагов" вроде mipmap и allowPicmip , которые образуют ключ, а сам поиск идёт по простой хеш-таблице, и десятки тысяч обращений к одной и той же текстуре стен gothic_block/blocks17 за матч в Quake 3 в реальности приводят к одной загрузке с диска и одному выделению VRAM, а всё остальное это просто возврат уже готового указателя.
Поэтому уровень с тысячей одинаковых факелов на стенах работает ровно с той же скоростью, что и уровень с одним факелом, при условии что VRAM хватает на сам ресурс хотя бы один раз. Unity в той же роли использует Resources.Load, который дедуплицирует по пути ассета и по настройкам импорта, а в более современной реализации ключом становится уже просто адрес и набор тегов, но идея та же самая, и когда вы в редакторе перетаскиваете один и тот же ScriptableObject в десять разных мест, в рантайме это всё ещё один объект, а не десять копий, и поменять у него поле через инспектор означает поменять его сразу везде.
Перезагрузка и пересоздание ресурса

В начале проекта это выглядит как «выгрузи и загрузи заново», рисуя в голове пару функций Unload и Load, которые вызываются при нажатии на кнопку «применить» в меню настроек. Проблема этой простой схемы, что она работает ровно до того момента, пока в игре есть хоть один ресурс, для которого художник или геймдизайнер явно сказал «не трогай его, мне нужно ровно так и никак иначе».
Как только такой ресурс появляется, что происходит примерно через первый рабочий день, наивный Unload+Load начинает затирать ручные настройки, и логотип компании на стене бара, который художник специально загрузил без mip-фильтра, после первого же reload получает все mips и превращается в мыло, а нормал-мапы лица главного героя, на которые потратили две недели, при смене настроек качества дружно ужимаются в два раза, потому что таково новое значение по умолчанию.
Поэтому правильная перезагрузка на второй день превращается в смену дефолтных параметров у одной группы ресурсов с одновременным пересозданием тех из них, чьи параметры действительно поменялись. При этом прегрузагрузка обязана уважать разделение параметров на явные и неявные, то есть менять только те значения, которые были взяты из дефолтов группы, и не трогать те, что художник или дизайнер явно указал при первоначальной загрузке.
С точки зрения API это означает, что у менеджера, помимо привычных Load и Create, появляется набор дефолтов и набор методов перезагрузки разной степени точечности, от «перезагрузи вот эту одну текстуру» до «перезагрузи всё, кроме UI», и эти методы не дублируют друг друга, потому что в реальной игре нужны и тот, и другой, и ещё пара промежуточных.
TextureManager::setDefaultMipFilter(Texture::mf_box_sharpen_soft);
TextureManager::reloadAllExceptGroup("ui");
TextureManager::setDefaultEffect(new ResizeBitmapEffect(vec2(0.5f, 0.5f)));Первая строка ставит новый дефолтный фильтр мипов, который сам по себе ничего не делает с уже загруженными текстурами, а только меняет правило для всех будущих загрузок и всех будущих перезагрузок.
Внеправильной реализации сеттер дефолта сразу же бы попытался обойти все текстуры и пересоздать их, не дав возможности атомарно сменить сразу несколько параметров. Вторая строка собственно и запускает массовую перезагрузку"ui".
Третья строка ставит ещё один дефолт, на этот раз эффект уменьшения вдвое, и формально по предыдущей логике она требует ещё одного reloadAll, но в реальной реализации сеттеры обычно объединяют в транзакцию и вызывают reload только один раз в конце, иначе при смене десятка параметров мы получили бы десять полных проходов по всему набору ресурсов с десятью обращениями к диску.
Игровых аналогов у этой механики масса, и самый очевидный это собственно смена настроек графики в меню паузы без выхода в главное меню и без полного перезапуска уровня. В современных играх это уже воспринимается как нечто настолько само собой разумеющееся, что игроки начинают возмущаться, когда натыкаются на исключения.
The Witcher 3 при переключении настроек качества меняет mip bias и параметры стриминга, и часть ассетов после этого пересобирается в фоне, причём пересобираются ровно те, чьи новые параметры отличаются от старых.
Ассеты диалоговых лиц или ключевых сюжетных моделей, у которых качество захардкожено в скрипте сцены, остаются как были, потому что иначе в кат-сцене у Геральта внезапно проседала бы детализация бороды, и весь художественный замысел сцены ломался бы из-за случайно нажатой галочки в меню.
Cyberpunk 2077 идёт ещё дальше и при смене DLSS, FSR или режима трассировки лучей не просто переcоздаёт часть render target и render chains, а ещё и перенаправляет внутренние очереди отрисовки на новые ресурсы, не теряя при этом сейв и не выкидывая игрока на загрузочный экран. Технически это очень сложная операция, которая внутри устроена ровно как загрузка игры с нуля, а не только текстур, но и моделей, и мешей, и render-pipeline-ресурсов.
Hot-reload

Отдельный поле для граблей с перезагрузкой это hot reload во время разработки. Это механизм, когда художник правит текстуру в своем инструменте, затем сохраняет, а движок замечает это изменение через файловую систему и без перезапуска игры, пересоздаёт ровно ту текстуру, чей файл поменялся, оставив все остальные нетронутыми.
В современных движках вроде Unreal или Unity hot-reload сводится к триггеру файловой системы на каталоге Content и точечному ReloadAsset(path), и здесь нужен отдельный механизм перезагрузки, потому что массовый reloadAll тут просто разрушил бы рабочий поток художника, заставив его ждать по минуте после каждого Ctrl+S.
Сам по себе этот механизм выглядит скромно, но на деле идея «перезагрузи меня по имени» быстро превращается в очень много кода, который умеет находить текстуру и в основной таблице CTextureHandler, и в отдельном кэше CTextureResource, причём не только по точному имени, но и по подстроке и по параметрами и по времени последнего изменения, что в условиях большого проекта с тысячами ассетов оказывается намного важнее, чем простая перегрузка с диска.
А еще современные инструменты вроде фотошопа или гимпа любят прятать небольшие изменения или историю изменений в файловых потоках, так что вы вроде Ctrl + S нажали и изменения на диск скинули, но движок их просто не видит, поэтому приходится "немножко шить по ночам", т.е. заниматься реверс инжинирингом форматов, которые движок должен поддерживать.
Другая причина почему движок не видит изменения это то, что фотошоп и гимп пишут файл не напрямую, а через временный файл с последующим переименованием, или держат файл залоченным пока идёт запись, и событие файловой системы прилетеает в момент когда файл ещё не дописан или дескриптор ещё не закрыт. Движок открывает файл, получает либо ошибку доступа либо неполные данные, что решается небольшой задержкой перед чтением или retry-логикой.
Пример из игры
class CTextureReloader : public CReloadDispatcher
{
private:
bool ReloadTextureByName( const CString& TextureName )
{
bool succeded = false;
CString textureNameLower = TextureName;
textureNameLower.ToLower();
CTextureHandler* pTextureHandler = CGraphics::Get()->GetTextureHandler();
HTexture tex = pTextureHandler->GetTexture( TextureName );
if ( tex )
{
pTextureHandler->ReloadTexture( pTextureHandler->GetTextureIndexMT( TextureName ) );
succeded = true;
}
else
{
CTextureHandler::Iterator End = pTextureHandler->IteratorEnd();
for ( CTextureHandler::Iterator It = pTextureHandler->IteratorBegin(); It != End; ++It )
{
const int nTextureIndex = ( *It ).second;
CString sName = pTextureHandler->GetTextureName( nTextureIndex );
sName.ToLower();
if ( sName.Find( textureNameLower ) != -1 )
{
pTextureHandler->ReloadTexture( nTextureIndex );
succeded = true;
}
}
}
CArray<CPtr<CTextureResource>> AllTextures;
GfxGetAllTextures( AllTextures );
for ( int i = 0, e = AllTextures.GetSize(); i < e; ++i )
{
CString sName( AllTextures[ i ]->_Filename );
sName.ToLower();
if ( sName.Find( textureNameLower ) != -1 )
{
const bool bReloaded = GfxReloadTexture( AllTextures[ i ].AccessPtr() );
succeded |= bReloaded;
}
}
return succeded;
}Тут видна философия перезагрузки, сначала мы честно пытаемся найти текстуру по точному имени и перезагрузить только её, и только если такого имени нет, расширяет поиск до подстроки и проходим по всем текстурам в обеих подсистемах, не делая при этом массового reloadAll всего на свете.
В таком виде идея перезагрузки дожила до современного кода почти неизменной. Инструменты вокруг неё обросли деталями, но сама суть осталась той же, что перезагрузка это не выгрузить и загрузить, а точечно подменить ровно те ресурсы, которые действительно нужно подменить, не трогая всё остальное и не теряя по дороге явно заданных пользователем параметров.
Потеря графического устройства

Самая нелюбимая всеми разработчиками графики штука, которая называется потерей устройства. Чтобы понять, откуда она вообще взялась и почему о ней нужно говорить отдельным пунктом, нужно вернуться в эпоху Direct3D 9 и вспомнить, что с этой модели графический контекст начал принадлежать не игре, а драйверу. Драйвер имел полное право в любой момент сообщить, что у нас больше нет ни VRAM, ни render targets, ни даже самого устройства, потому что пользователь нажал Alt+Tab, или потому что Windows ушла в сон, или потому что драйвер NVIDIA решил пересоздать DXGI-объект для своих внутренних нужд, и в этот момент вся загруженная видеопамять превращалась в тыкву, а игра должна была не упасть, не показать чёрный экран и не потерять прогресс, но восстановить всё, что было до device lost.
Чтобы хоть как-то ужиться с этой новой реальностью, D3D9 ввёл понятие пулов памяти, и каждый ресурс при создании надо было относить к одному из трёх вариантов, у каждого из которых был свой профиль поведения при reset. Ресурсы в D3DPOOL_DEFAULT жили прямо в VRAM и были самыми быстрыми в работе, но при потере устройства теряли всё содержимое и должны были пересоздаваться вручную, ресурсы в D3DPOOL_MANAGED дублировались драйвером в системной памяти, благодаря чему после reset драйвер сам восстанавливал их содержимое, что было удобно, но за это приходилось платить двойным расходом памяти и невозможностью использовать их как render targets, а ресурсы в D3DPOOL_DYNAMIC жили в специальной области для часто меняющихся данных и создавались каждый кадр игрой.
Из этого зоопарка пулов возникает необходимость, что менеджер ресурсрв теперь обязан у каждого ресурса хранить и особенность его жизни в драйвере, потому после потери устройства невозможно будет пересоздать ресурс идентичным. Теперь при получении сигнала о потере устройства надо освободить всё, что было в VRAM, не теряя при этом сами объекты-обёртки и накопленные параметры, потом при reset пройтись по всем своим ресурсам и пересоздать каждый с теми же исходными данными и с теми же параметрами, что были изначально, либо восстановить их из managed-копии, если такая была.
В современных Vulkan, DX12 и Metal никаких пулов больше нет и такое понятие как lost device вроде бы исчезло, но если копнуть поглубже, то выяснится, что вся та же история живёт под другими именами и с чуть более непонятными сообщениями. В DXGI теперь есть код DXGI_ERROR_DEVICE_REMOVED, который прилетает на любой Present или ExecuteCommandLists после того, как драйвер решил, что с устройством что-то не так.
Например после ошибки таймаута TDR или после переключения адаптера в ноутбуке между интегрированным и дискретным GPU, и в ответ на это игра обязана выкинуть всё, что у неё было, пересоздать device, swapchain, command queue, все ресурсы и pipeline state objects, и продолжить рендерить как ни в чём не бывало, и именно это спасает большинство PC-игр 2015 года и моложе от того, чтобы упасть на ровном месте.
В Vulkan ситуация выглядит чуть мягче, потому что устройство там формально не теряется и просто продолжает работать, но swapchain имеет полное право вернуть VK_ERROR_OUT_OF_DATE_KHR при смене разрешения окна или при пересоздании поверхности окна, и в ответ на это надо запустить тот же самый цикл invalidate plus recreate для всего, что концептуально ничем не отличается от reset эпохи D3D9, только проблема ловится в одном месте, а не размазана по всем командам.
На Nintendo Switch с его NX видеоподсистемой вся эта история выходит на уровень, когда любое усыпление консоли и любое переключение между портативным и стыкованным режимом фактически приводит к потере GPU-контекста, и движок обязан пересоздать все то же самое содержимое после resume, иначе игрок выходит из спящего режима в чёрный экран.
И если теперь посмотреть на весь этот механизм, который у нас получился, то окажется что менеджер ресурсов превратился в систему Undo/Redo. И как фотошоп помнит каждый мазок кисти, менеджер ресурсов теперь обязан помнить каждый созданный GPU-объект достаточно подробно чтобы его пересоздать. Разница только в направлении и Undo идёт назад, а восстановление после device lost идёт вперёд по той же самой истории команд.
Именно поэтому «просто создать ресурс» оказывается таким объёмным куском кода, когда "создать" это одновременно значит и записать в журнал, и чем подробнее запись, тем дешевле обходится потом работа с ресурсом.
Расширяемость без переписывания ядра

Самой неочевидной стороной менежера ресурсов становится не как ресурс себя ведёт во время игры, а как система ресурсов будет переживать своё собственное развитие на горизонте в год, три и пять лет, когда в проекте появятся новые типы данных, новые форматы файлов, новые подсистемы рендера и новые требования от художников.
Каждое из этих изменений не должно превращаться в правку ядра менеджера, что звучит абстрактно-архитектурно, но в плохой реализации добавление нового типа ассета вроде «теперь у нас будут voxel-чанки» или «давайте поддержим неонокарты для светящихся надписей» заставят лезть в файл с уже существующим TextureManager, добавлять туда новый enum, новый switch-case и новые поля. Через десяток таких правок этот файл превращается в комбайн на десять тысяч строк, который никто не решается трогать, потому что любое движение в нём что-нибудь неожиданно ломает.
В идеале менеджеры разных типов ресурсов это разные сущности, и TextureManager ничего не знает про шейдеры, а ShaderManager ничего не дожен знать про вершинные буферы, и общего у них на уровне кода ничего нет, кроме идеи. Но эта самая идея, что у нас есть пара «источник плюс параметры», что есть кэш по этому ключу, что есть подсчёт ссылок и что есть умение пересоздать ресурс после потери устройства, повторяется во всех менеджерах одинаково.
Поэтому делать каждый менеджер отдельно означает либо размножать одни и те же баги по разным углам кодовой базы, либо чинить одну и ту же проблему по три раза. Поэтому в нормальной системе под всеми менеджерами лежит общая шаблонная база, которая отвечает за всю эту скучную, но обязательную механику, и оставляет конкретному менеджеру специфику работы с его типом ресурсов. Например то, как именно из DDS-файла получить HTexture, или как из HLSL-исходника собрать шейдерный объект.
Теперь "добавление нового типа" будет "добавление новыго менеджера плюс регистрация", потому что у каждого типа свой менеджер и под всеми лежит общая база, и новый тип ассета это просто новый класс, отнаследованный от ресурса, и новый менеджер, отнаследованный от шаблонного кэша, и нигде в существующих файлах не нужно дописывать ни одного оператора switch.
В качестве примера как эта схема выглядит в большом коммерческом движке, удобно посмотреть на Unreal Engine, где базовым типом является UObject, ниже него живёт UAssetManager, который ничего не знает про конкретные ассеты, а только умеет вести учёт их primary-айдишников и группировать по типам.
Сами фабрики живут отдельно для каждого типа, так что для UTexture есть свой набор UTextureFactory-подобных классов, для UStaticMesh свой, для USoundWave свой, и добавление нового типа ассета, какого-нибудь UProcMaterial или UNiagaraEmitterAsset, идёт ровно по сценарию «новый класс плюс регистрация фабрики», без правок ядра UAssetManager.
В Godot та же самая логика, но там вместо фабрик есть наследники Resource, а вместо большой иерархии менеджеров есть один ResourceLoader, который умеет находить нужный загрузчик по расширению файла, и опять же добавление нового типа ассета сводится к написанию класса-наследника и регистрации соответствующего ResourceFormatLoader, и ResourceLoader сам по себе при этом не трогается, потому что он работает не с конкретными ассетами, а с интерфейсом загрузки, и ему всё равно, что именно грузится, статичная сетка или процедурная текстура.
Цена этой расширяемости, как часто бывает, почти не видна если вы не лезете в код движка. Только в одном менеджер текстур моего текущего проекта получается около 700 функций суммарно, из которых около 200 это разные варианты load, еще гдето 100 это reload, еще 200 это create, и весь этот объём сгенерирован из данных шаблонного генератора, который один раз настроили под список параметров текстуры и потом просто прогоняли заново, когда нужно было добавить или убрать параметр.
Эта цифра, если подумать, и есть настоящая цена того, чтобы программист писал load(path, compression, effect) в одну строчку и не задумывался, как именно эта строчка превращается в правильный вариант загрузки, и именно сюда уходит вся экономия на удобстве API, которые мы получаем в верхнем слое движка.
class CResource : public CRefCountable<CResource>
{
public:
CResource()
: _pCache( NULL )
{
}
protected:
template <typename, typename, typename, typename, typename>
friend class CResourceCache;
virtual ~CResource() = default;
virtual void Delete() const { delete this; } // Give a chance for subclasses to handle their own delete
friend void RefCounted_AddRef( const CResource* pResource );
friend void RefCounted_Release( const CResource* pResource );
CResourceCacheBase* _pCache;
};В этом классе нет ни типа данных, ни имени файла, ни загрузки, ни параметров. Это сознательное решение, и весь смысл этой в том, что любой конкретный тип ресурса от текстуры до меша и до шейдерного объекта строится как наследник CResource, добавляющий ровно те поля, которые ему нужны, тогда как механика жизненного цикла, общая для всех.
Весь сложный код, связанный с кэшированием по ключу, с многопоточной защитой, с обработкой удаления и с выдачей слабых ссылок, унесён в шаблонный CResourceCache, который параметризуется тремя вещами, типом самого ресурса, типом ключа и типом мьютекса для блокировки:
template <typename ItemType,
typename KeyType = CString,
typename LockType = CMutex,
typename TKeyHasher = SHash<KeyType>,
typename TKeyEquals = std::equal_to<KeyType>>
class CResourceCache : public CResourceCacheBaseИз этого шаблона дальше уже выводятся конкретные кэши под текстуры, под меши, под шейдерные программы и под всё остальное, что в движке требует разделяемого владения и дедупликации по ключу, и каждый из них получается фактически бесплатно для архитектуры, в смысле что под него не надо добавлять отдельный код управления ссылками, отдельную потокобезопасность или отдельную обработку удаления, всё это уже реализовано в шаблоне и проверено на одном экземпляре кода, который дальше просто переиспользуется.
И когда завтра в проекте захочется завести, например, новый тип ассета вроде кэша скриптовых иконок миссий или кэша звуковых клипов диалогов, разработчику не придётся переписывать ни GfxGetTexture, ни обходчики OnLostDevice, ни механику reload, потому что он просто добавит новый класс-наследник CResource со своими полями и заведёт под него инстанс CResourceCache<MyNewResource, MyKeyType>, и вся ресурсная инфраструктура движка автоматически распространится на новый тип.
Эта возможность спокойно добавлять новые типы ассетов годами без правки центральных файлов и без страха сломать то, что давно работает, и есть настоящий тест расширяемости любого ресурсного менеджера, потому что красивый API на старте проекта рисует кто угодно, а вот пережить десять лет коммитов разных команд с разным уровнем понимания архитектуры и не превратиться при этом в TextureManager.cpp на пятнадцать тысяч строк со ста семьюдесятью пятью switch case по типу ассета, может только система, в которой типы ресурсов изначально оторваны друг от друга, а вся общая механика честно вынесена в одну шаблонную базу.
Параметры и группы как ключи

Если первая половина статьи была про то, что менеджер обязан делать снаружи, то теперь можно поговорить про то, как он устроен внутри, и центральную роль в этом устройстве играют параметры, потому что именно от их выбора зависит насколько удобно будет программисту жить с этой системой, насколько быстро она будет работать и насколько безболезненно её получится развивать.
Первое правило хорошего менеджера ресурсов по нынешним меркам data-driven подходов выглядит жестоко. Набор параметров для каждого типа ресурса должен быть фиксированным и заданным на этапе дизайна системы, а не выбираться на лету из произвольного JSON или YAML, как это любят делать современные движки, где конфигурация ассета часто выглядит как открытый словарь (Unity подход), в который можно положить почти что угодно.
Чтобы понять, почему это так, надо посмотреть на то стандартный набор параметров для текстуры, то есть пул памяти, в котором текстура будет жить, сжатие, которое определяет и формат данных, и итоговый размер в VRAM, число генерируемых mip-уровней, фильтр генерации этих самых mip, и наконец отдельный bitmap effect, в роли которого может выступать что угодно.
Каждое из этих полей это явный, конкретный, заранее продуманный интерфейс, и если попытаться в этот список добавить какое-нибудь новое поле, например цвет фоновой подсветки или комментарий художника, выяснится, что они там не нужны, потому что они не влияют ни на то, как ресурс выглядит в памяти, ни на то, как он рендерится.
Значит они не имеют права формировать идентичность ресурса и быть частью ключа кэша и ровно из этого вытекает первая польза от фиксированного набора, когда любой параметр, который сидит в структуре загрузки, обязан реально что-то менять в данных или в их представлении, а если он туда добавлен «на всякий случай», то он гарантированно ломает либо кэш, либо перезагрузку, либо восстановление после device lost.
Вторая польза от фиксированного набора параметров появляется, когда менеджер начинает выдавать что-то наружу и принимать что-то снаружи, и имея заранее фиксированный набор можно описать его одной структурой, передавать её по значению, сравнивать побайтово, хешировать в одну функцию, сериализовать и так далее.
А открытая мапа из строки в variant заставляет при каждом обращении строить строковые ключи, ловить опечатки, проверять, что в значение положили правильный тип, и аккуратно сравнивать значения с учётом того, что у двух эквивалентных мап порядок ключей может быть разным.
Любой, кто хоть раз отлаживал багу в Unity-проекте когдаResources.Load вернул кэшированную копию ассета, потому что новая опция импорта попала не в ключ кэша, а в какую-то соседнюю карту, очень хорошо понимает, чем это в реальности заканчивается, и что добавление цвета как параметра текстуры это ровно про этот класс ошибок.
Повторюсь что любое расширение списка параметров обязано быть осознанным шагом дизайна, а не побочным эффектом того, что кому-то в коде понадобилась лишняя переменная.
Не менее важная вещь, проявляется уже послен написания всей системы, в момент его чтения через год или коллегами, потому что фиксированная структура параметров это фактически документация интерфейса, и человек, открывший заголовок ресурсного менеджера, сразу видит, что вот эти восемь полей это всё, что можно сказать про загрузку текстуры.
Ему не нужно лезть в десять других файлов, чтобы выяснить, какие ещё неявные ключи в каких ещё конфигах меняют поведение, тогда как в схеме «JSON на лету» эта документация физически отсутствует, и единственный способ узнать поддерживаемые ключи это либо найти место, где их парсят, либо открыть конфиг прошлогоднего проекта и надеяться, что там собран полный список.
Особенно болезненно это становится в больших проектах вроде Cyberpunk, где над движком много лет работают разные команды, и любая возможность «доложить ключ в json и где-то его потом заметить» обязательно превращается в сотни таких ключей, разбросанных по моду и базовой игре, и поведение ресурса начинает зависеть от мутной комбинации настроек, которые формально нигде и не задокументированы вовсе, добро пожаловать в большой проект, как говорится.

При этом важно не путать фиксированный набор параметров с отсутствием data-driven подхода вообще, потому что само значение каждого параметра вполне может задаваться из конфига, из импорт-настроек ассета или даже из аргументов командной строки, и в этом нет никакого противоречия с правилом фиксированного набора параметров, потому что речь идёт не про источник значений, а про набор полей структуры, в которую эти значения кладутся.
То есть художник может в meta-файле текстуры написать mipFilter: Mitchell и compression: BC7, и это абсолютно нормально, но за тем, что у текстуры существует ровно эти два поля и ровно с такими значениями, следит C++-структура, объявленная на этапе дизайна движка, и любая попытка положить в этот meta-файл, например, coolnessLevel: maximum, должна на этапе импорта приводить к ошибке, а не игнорироваться, потому что иначе со временем эти coolnessLevel начинают увеличиваться в числе и захламлять данные смыслом, который понимает только владелец этой текстуры.
struct STextureOptions
{
STextureOptions();
COptional<SColorSubstitution> _ColorSubstitution;
uint _nMipSkipCount = 0U;
bool _bReportMissingTexture = true;
bool _bForcePOW2 = false;
bool _bIssRGB = false;
bool _bSaveAlpha = false;
bool _bUseMinMipSkip = true;
};И вот эта простая на вид структура, поселившаяся в заголовке движка где-то в середине девяностых строк, и есть практическое воплощение правила «фиксированный набор параметров на тип», потому что её можно скопировать, сравнить, захешировать, отправить в кэш как часть ключа, сериализовать на диск, разобрать обратно и при этом всегда знать, что в ней лежит, без оглядки на то, кто и когда дописывал новые свойства в json соседнего ассета.
Именно из этой предсказуемости вырастает вся остальная конструкция, от групповых дефолтов до точечной перезагрузки, потому что менять и переопределять можно только то, набор чего изначально известен.
Смена настроек без полного рестарта

Теперь надеюсь, у вас в голове сложилась цельная картинка устройства менеджера ресурсов, что ресурсов - это данные с фиксированным набором параметров, из групп с собственными дефолтами и из явных-неявных значений с флажком при каждом параметре.
Вернемя к тому, ради чего вся эта механика и затевалась, потому что в качестве главного оправдания всех этих архитектурных усилий является именно смена настроек графики прямо во время игры. Да, все это оказалось нужно, чтобы обеспечить условную простоту 5 минут времени в меню.
Чтобы понять разницу между хорошей реализацией и плохой, удобне будет сначала описать плохой путь, к которому многие движки приходят естественным образом, а потом разобрать хороший, который требует больше работы на старте, но потом окупается каждый раз, когда игрок лезет в меню чтото менять.
Дешево и быстро
Самая простая и интуитивно понятная реализация смены настроек графики выглядит так, что мы записываем новые значения в конфиг, потом честно убиваем всю загруженную сцену со всеми текстурами, мешами, шейдерами и рендер таргетами, и потом загружаем мир заново, теперь уже с новыми глобальными значениями параметров.
Формально тут всё правильно, потому что новый мир получается полностью новым, нигде не остаётся старых ресурсов с прежними настройками, и состояние движка после смены настроек точно такое же, как если бы мы запустили игру с этими параметрами с самого начала.
Из-за этой простоты и формальной корректности подход живёт во многих движках, а мобильные игры особенно охотно идут по этому пути, потому что у них старт сцены укладывается в три-пять секунд, и игрок терпит такую паузу без особых возражений, тем более что меню настроек на мобилке открывают раз в полгода.
Проблемы начинаются когда речь заходит о больших играх с долгим стартом уровня, и тут можно показать пальцем почти на все игры до конца десятых годов, где смена настроек графики фактически означала, что движок выгружает всю карту и потом загружает её заново, а это в условиях больших уровней занимало от трёх до пяти минут реального времени.
Игрок, который зашёл в меню просто поэкспериментировать с галочками, обнаруживал, что включить её занимает четыре минуты, посмотреть на результат полминуты, понять, что эффект так себе, выключить обратно ещё четыре минуты, и в итоге каждое касание настроек графики превращалось в самостоятельный квест, на который многие забивали после первых двух попыток.
Такой подход не годится для серьезных игр, потому что игрок настройки графики трогает не один раз за всю жизнь игры, а как минимум несколько раз при первом запуске, потом при выходе обновления, потом при выходе патча, потом при покупке длц, и каждое такое касание не должно стоить ему пятм минут реального времени.
Долго и дорого
Альтернативный подход, который используется в современных движках, строится на том, что мы не убиваем мир, а используем механизм перезагрузки, чтобы поменять только то, что реально нуждается в изменении. На что новые настройки не влияют, остается нетронутым.
Идея до неприличия проста и сводится к трём шагам, которые повторяются каждый раз, когда игрок что-то меняет в меню. Первый шаг, выполняемый ещё на этапе дизайна, состоит в том, чтобы разбить все ассеты на группы, привязанные к настройкам графики, и сделать это так, чтобы ни одно изменение настройки не требовало перезагрузки части группы, а только всей группы целиком.
То есть если у нас в меню есть отдельная опция «качество теней», то под неё должна существовать своя группа shadows, в которую попадают теневые карты, shadow-каскадные render targets, downsampled-копии и прочие специфические для теней ресурсы, и при изменении этой опции мы будем перезагружать именно её, не трогая ни UI, ни обычные текстуры мира.
Второй шаг это уже runtime-реакция на изменения, и он зовёт сеттер дефолта для соответствующей группы с новым значением, и сразу за ним зовёт reloadGroup той же группы. После чего менеджер обходит все ресурсы группы и пересоздаёт те из них, у которых текущее значение этого параметра было неявным, то есть взято из дефолта, и не трогает те, где оно было задано явно при загрузке.
В коде это выглядит примерно так
TextureManager::instance().setDefaultShadowResolution(1024)
TextureManager::instance().reloadGroup("shadows")И весь процесс смены настройки теней занимает ровно столько, сколько нужно на пересборку самих теневых ресурсов, без выхода из карты и без перечитывания десятков мегабайт мировых текстур, к которым качество теней вообще не имеет отношения.
И это, в сущности, и есть та самая «фича перезагрузки», ради которой собственно и затевался весь огород с явными и неявными параметрами, потому что без отдельного хранения этой пары вместе с каждым ресурсом мы бы при любой смене дефолта группы стирали все художественные правки и тонкие настройки конкретных ассетов.
Побочным эффектом получившейся конструкции будет, что при старте игры мы можем не делать ничего для восстановления настроек прошлой сессии, а просто прочитать config-файл с пользовательскими настройками, ровно теми же сеттерами выставить дефолты всех групп, и после этого начать грузить контент. Тогда все ресурсы при первой же загрузке получат правильные значения параметров, не унаследовав ничего лишнего от дефолтов, заложенных программистом в код, потому что для них дефолтом группы уже будет то, что выбрал пользователь.
Современные большие игры в самых удачных своих воплощениях работают именно по этой схеме. Хорошим примером тут будет Overwatch, который умудряется при смене качества эффектов или теней не перезапускать матч, не выходить в лобби и даже не делать заметной паузы, и игрок может подобрать комфортный пресет, ничего не теряя из своего состояния и переключение пресета это смена дефолта плюс точечный релоад ассетов, которые от этого пресета зависят.
Red Dead Redemption 2 идёт в ту же сторону и смена пресетов происходит без выхода в главное меню и без полного рестарта мира, и в благоприятных условиях игрок видит только короткое мерцание, после которого мир продолжает крутиться с уже новыми настройками.
Во всех этих случаях за внешней магией стоит ровно та же самая трёхшаговая схема, просто завёрнутая в более сложные стриминговые движки. Необязательно убивать мир, чтобы поменять у него одну ручку.
Что есть в современных движках

Менеджер ресурсов имеет вполне стандартное архитектурное воплощение, узнаёмое и в коммерческих движках, и в небольших opensource-проектах. Идея идентичности ресурса по паре источника и параметров на уровне технологии чаще всего реализуется через классический паттерн Flyweight плюс кэш, в котором кэш сам по себе и есть гарантия того, что два запроса с одинаковыми ключами получат один и тот же экземпляр.
Эту схему легко увидеть в Unity, где Resources.Load устроены по такому принципу, в Unreal с её разделяемыми UObject и в Source с его материалами и текстурами. Сам кэш как сущность это почти всегда обычная хеш-таблица плюс подсчёт ссылок, и тут уже сложно найти AAA-движок, в котором было бы как-то по-другому, потому что эта связка проверена годами и не имеет альтернатив, разве что в каких-то экзотических случаях добавляют ещё и слабые ссылки или LRU поверх.
Разделение параметров на явные и неявные, пожалуй, документировано хуже всего, потому что эту механику просто не выносят в публичный API и она живёт где-то под капотом, но идею легко узнать по системе per-asset override, где для отдельной текстуры можно сказать «этот параметр у меня переопределён, не трогать его при смене групповых настроек», и фактически это и есть тот самый флажок explicit, просто завёрнутый в редакторскую галочку.
Точечная перезагрузка группы технически реализуется cтримингом, её хорошо видно в Witcher 3, в Cyberpunk или в Unreal Engine, где смена качества пересобирает рендер таргеты, не выкидывая игрока в главное меню.
Кодогенерация API под большое количество комбинаций параметров чаще всего выглядит как внешний скрипт на Python или Perl, который выдаёт C++-заголовки, в Unreal похожую роль играет Unreal Header Tool, который порождает много вспомогательного кода вокруг рефлексии и сериализации ассетов.
Наконец, разделение менеджеров по типам это технологически почти всегда type-specific singletons или type-specific subsystems, в Unreal Engine это subsystems, разнесённая по уровням engine, editor, game и local player.
Что менялось за 20 лет
На месте D3DPOOL_MANAGED и D3DPOOL_DEFAULT, которые в нулевых определяли всю стратегию работы с VRAM, сегодня формализованы вплоть до уровня DXGI_MEMORY_SEGMENT_GROUP, и хотя названия изменились, но разработчик по-прежнему должен думать, что у него лежит в быстрой видеопамяти, что в медленной системной и кто кого убивает при нехватке места.
Старое понятие lost device тоже не выжило в виде названия, но превратилось в device removed для DX12, в смену swapchain для Vulkan, и потерю контекста на Android и Switch, и каждое из этих имён описывает свой узкий случай, но сам процесс восстановления остался тот же, то есть выкинуть всё, пересоздать device и контекст, и заново родить ресурсы по сохранённым описаниям.
Mip-фильтры формально сохранились, но в современном продакшене они оказались задвинуты на второй план целым этажом более продвинутых техник вроде GPU-driven rendering, streamable mips и virtual texturing, где идея «у текстуры есть фиксированное число мипов, и они генерируются вот таким фильтром» уже не покрывает реальность, в которой текстура существует во множестве частей и уровней детализации, подгружаемых по требованию.
Шейдерные#define, сегодня живут в виде shader variants и permutation cache в куче движков, и количество комбинаций уже исчисляется не десятками, а тысячами и десятками тысяч, что в свою очередь породило отдельную инфраструктуру их предкомпиляции и кэширования между запусками.
Но несмотря на все эти изменения в терминах и в техническом железе, основная конструкция менеджера ресурсов остается практически нетронутой, и если выкинуть из неё конкретные имена технологий, то всё остается актуальным и двацать лет спустя.
Ресурс по-прежнему определяется как данные плюс параметры загрузки, кэш по-прежнему строится по ключу из этой пары, дефолты по-прежнему имеет смысл группировать и переопределять не глобально, а по группам, перезагрузка по-прежнему выгоднее в форме точечного reload, чем в форме «убей всё», описание загрузки по-прежнему обязано храниться рядом с ресурсом для возможности восстановления.
Эти базовые вещи концепции оказались устроены настолько фундаментально, что пережили и смену GPU API, и смену поколений консолей, и появление SSD как нового уровня памяти и переход от настольных к мобильным GPU, и продолжают работать ровно так же, какими были в начале нулевых.
TL;DR
Если попытаться одним абзацем выразить вест текст выше, то оно будет звучать примерно так. Менеджер ресерсров это не очередной паттерн Singleton применительно к текстурам, а проработанная архитектура жизненного цикла ассетов, в которой есть понятие идентичность ресурса, кэша, и групповые политики дефолтов, и два разных по природе вида перезагрузки, аварийный для потерянного устройства и пользовательский для смены настроек, и удобный для программиста API, в который этот многоуровневый механизм аккуратно завёрнут. Хорошая архитектура отличается от модных в этом году паттернов как раз тем, что её формулировки переживают и API, на которых она была впервые описана, и поколения консолей, в которых она впервые проверена, и команды, которые её писали, и продолжают приносить пользу там, где уже даже не помнят имена людей, её придумавших.
З.Ы. Всё написанное выше достаточно точно описывает менеджеры ресурсов до примерно 2022 года, этот период я знаю по личному опыту в продакшене. Дальше начинается территория где у меня больше чтения чем практики, и три темы заслуживают отдельного разговора: bindless rendering, Nanite и PSO cache. Если вы работаете с современным стеком, эти темы стоит изучать по более свежим материалам.
З.З.Ы. КДПВ взяты с замечательного сайта https://refactoring.guru/ с их разрешения и немного доработаны напильникомнанобананой.

















