Asset Usage Audit

Плагин редактора UE 5.6: показывает, какие ассеты используются на каких уровнях, и какие не используются нигде. Отмеченное галочками выгружается файлами или отчётом.

Задача: OV — «подготовить утилиты для UE по экспорту ассетов, используемых в локациях».


Быстрый старт

  1. Window → Tools → Asset Usage Audit (или в консоли AssetUsageAudit.OpenPanel)
  2. Выбрать локацию в списке Level — это область прогона, а не фильтр результата
  3. Run Audit
  4. Отфильтровать по типу, отметить галочками
  5. Внизу — Export report (JSON+CSV) или Export ticked assets…, открывающая окно выгрузки

Двойной клик по строке показывает ассет в Content Browser — как Browse To в редакторе. Ассет при этом не загружается: строка может быть картой или мешем на сотни мегабайт.

Полный свип по всем 1072 уровням — ~1–3 с. По одному уровню — быстрее.


Интерфейс

Тулбар

Контрол Назначение
By asset / By level Направление вопроса. By level строит дерево: уровни-заголовки, под ними ассеты. Без выбранного уровня показывает все уровни сразу — так сравниваются две локации. Галочка на заголовке отмечает всё под ним; частичный выбор рисуется третьим состоянием, а не полной галочкой
Level Область аудита, чеклист с множественным выбором. Пусто = все уровни. Смена помечает результат устаревшим, но не запускает прогон: «All levels» это 1072 уровня
Types Сверху пресеты (StaticMesh, Material, VFX, Sound…), ниже все 136 сырых классов. Пресет разворачивается в подклассы, поэтому Material ловит и MaterialInstanceConstant; пресеты, которых в проекте нет, не показываются. Повторный клик по выбранному пресету снимает его целиком
Blueprint class Подстрока геймплейного класса. Непустое значение скрывает не-блюпринты
Verdicts Пять состояний, все включены по умолчанию
References Как ассет удерживается и через что достигнут
Поиск По имени и пути

Колонки

Check · Type · Name · Path · Verdict · Levels · Hard · Soft · Provenance · Route

Route — цепочка, объясняющая вердикт:

WP_Example → BP_Child_C_UAID_... → BP_Master → SM_Station

Вердикт без маршрута для художника бесполезен, поэтому маршрут пишется всегда.

Type показывает класс ассета (Blueprint), геймплейный класс — в тултипе. Это сделано намеренно: если бы в колонке стоял BP_Pickup_Child_C, строка попадала бы под фильтр Blueprint, и таблица противоречила бы сама себе.


Вердикты

Пять состояний, никогда не булево.

Вердикт Значение
UsedOnLevel Достижим от карты. Колонки уточняют hard/soft и через какой уровень
UsedByAssetsOnly Есть референсеры, но ни одна цепочка не доходит до карты
ReferencedFromConfigOrSource Найден grep-ом по Config/ и Source/. В провенансе — файл и строка
Unreferenced Ни одной входящей ссылки
Unknown Попадает в слепую зону реестра

⚠️ Unknown — это не «не используется»

Asset Registry принципиально не видит:

  • пути, собранные конкатенацией строк в C++/BP;
  • OpenLevel(FName);
  • строки внутри DataTable — уровень зависит от таблицы целиком, поэтому любая строка выглядит используемой;
  • FMOD — резолвит события строковыми путями мимо UObject-графа. Аудио систематически попадает в Unknown.

Инструмент никогда не удаляет и не предлагает удалить. Только отбор и выгрузка.

Смягчение: скан Config/ и Source/ по \/Game([A-Za-z0-9_.\/]+)\b. Без него GameMode, GameInstance и стартовая карта помечались бы мусором — на них не ссылается ни один ассет, только DefaultEngine.ini.


Настройки

Project Settings → <Проект> → Asset Usage Audit. Пишутся в Config/DefaultEditor.ini — файл под контролем версий, настройки общие для команды.

Состояние панели (выбранный уровень, типы, режим выгрузки, чекбокс зависимостей) хранится отдельно — в Saved/Config/.../EditorPerProjectUserSettings.ini, пер-юзерно и вне контроля версий. Сохраняется при закрытии вкладки; аварийное завершение редактора теряет изменения. Строка поиска намеренно не восстанавливается: панель, открывшаяся пустой из-за забытого фильтра, выглядит сломанной.

Параметр По умолчанию
DefaultExportDirectory пусто → Saved/AssetUsageAudit
bOverwriteExistingFiles false → индексирование имён
ExcludedPackagePaths 3rdParty, StarterContent, StarterBundle, Megascans, MSPresets
IncludedPackagePaths пусто → /Game
bScanIndirectReferences true
bHideExternalPackages true
bMirrorFolderStructure false → плоская папка
ExchangeFormatByClass пусто → встроенные умолчания

⚠️ Покупной пак не следует исключать вслепую: он может поставлять класс, который DefaultEngine.ini назначает GameMode или GameInstance проекта. Проверяйте ExcludedPackagePaths против конфига.


Консольные команды

AssetUsageAudit.OpenPanel
AssetUsageAudit.Run [/Game/Maps/YourLevel ...]
AssetUsageAudit.FindUnused

Без аргументов Run обходит все уровни. Первый вызов после старта редактора ждёт догрузки Asset Registry.

Blueprint / Python

Категория Asset Usage Audit: RunAudit, GetAssetsUsedOnLevel, GetLevelsUsingAsset, MeasureFullSweepSeconds.


Отчёты

Пишутся парой, Saved/AssetUsageAudit/:

  • JSON — вложенные списки уровней, метаданные фильтров
  • CSV — UTF-8 с BOM, разделитель ;, многозначные поля через |

BOM обязателен: без него Excel ломает кириллицу. Шапка CSV содержит применённые фильтры, версию движка, длительность и предупреждение про Unknown.


Выгрузка файлов

Все параметры собраны в отдельном окне: кнопка Export ticked assets… внизу панели. Наверху остались только фильтры и Run Audit — режим, раскладка, папка и зависимости трогаются лишь в момент выгрузки, и окно может показать, к чему они приведут.

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

Раскладка папок

Вариант Что делает
Flat Всё в одну папку. Ради этого и существует политика имён
Mirror the content tree Воспроизводит дерево /Game
One folder per asset Каждому отмеченному ассету — своя папка, зависимости рядом
Migrate into another Unreal project Передаёт всё движковому MigratePackages. Единственный вариант, после которого ссылки работают

⚠️ Копия .uasset не переносит зависимости сама по себе

Ссылки внутри .uasset — это полные имена пакетов (/Game/Art/T_Rock_D), а не относительные пути. Файл резолвится, только если лежит ровно по этому пути от Content/ в целевом проекте.

Отсюда: Flat и One folder per asset дают файлы для людей, а не для движка. Скопированные в проект, они откроются с битыми ссылками. Диалог говорит это прямо в подсказке под настройками.

Для переноса в другой UE-проект есть Migrate. Указывать надо папку Content/ целевого проекта.

⚠️ Папка назначения обязана удовлетворять двум условиям движка, иначе перенос молча не состоится:

  1. путь оканчивается на /Content/;
  2. на уровень выше лежит .uproject или ровно один .uplugin — из этого движок выводит точку монтирования.

Проверка повторена у нас (AssetUsagePaths::ValidateMigrateDestination) и показывается прямо в окне: кнопка Export гаснет, причина стоит внизу. Так вышло не от аккуратности — первая версия отправляла папку Saved/AssetUsageAudit, движок отвечал does not appear to be a game Content folder только в Output Log, и в панели не происходило ничего. При переключении на Migrate подставленный путь сбрасывается: он заведомо непригоден.

Про Migrate стоит знать:

  • Ему передаются только отмеченные пакеты — замыкание он строит сам. Скармливать ему ещё и наше означало бы тот же результат медленнее, с чужими ассетами в его отчёте.
  • MigratePackages возвращает void и отчитывается сам. Поэтому в статусе панели не будет числа скопированных файлов: подделывать его нельзя.
  • Политика имён к нему неприменима — Migrate обязан положить пакет по исходному пути, иначе теряется весь смысл. Keep both вырождается в Skip.
  • ⚠️ Снятая галочка зависимостей означает bIgnoreDependencies, а он, по комментарию движка, не переносит OFPA-акторов уровня. Уровень приедет пустым. Панель спрашивает подтверждение отдельно.

Манифест зависимостей

Для раскладок, ломающих ссылки, рядом с файлами пишется AssetUsageAudit.manifest.json — галочка Write dependency manifest, включена по умолчанию.

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

⚠️ Фактический путь, а не задуманный. Экспортёр возвращает карту записанного (bRecordWrittenFiles), и манифест строится из неё. Манифест из задуманных путей врал бы ровно в случае сработавшей политики имён — то есть когда он нужнее всего.

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

⚠️ One folder per asset пишет больше файлов, чем ассетов. Текстура на сорока мешах копируется в сорок папок — в этом и смысл: папку можно отдать целиком. Диалог подтверждения называет число файлов, а не число ассетов; это разные числа, и путать их дорого.

Галочка Sort dependencies into type subfolders раскладывает зависимости внутри папки ассета по типам — Texture/, Material/, StaticMesh/. Имена берутся из пресетов фильтра типов, а не выдуманы отдельно: в фильтре и на диске должны быть те же слова. Сам ассет остаётся в корне своей папки — он её предмет. Галочка активна только при One folder per asset; при других раскладках она выключена, но видима — исчезающий контрол читается как поломка.

⚠️ Include dependencies — включено по умолчанию

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

Поэтому по умолчанию выгружается замыкание: отмеченное плюс всё, на что оно ссылается, транзитивно. Перед записью показывается диалог с реальным числом — «отмечено 40, будет записано 380». Это та цифра, которая останавливает человека, случайно выгружающего пол-проекта.

Границы обхода:

Запрос тот же NoRequirements — soft-ссылки ловятся наравне с hard
/Engine, /Temp не копируются — в целевом проекте они уже есть, перезаписать хуже, чем пропустить
/Script не копируются, это код
Исключения путей те же, что у аудита: выгрузка не тянет то, что отчёт игнорирует
Отмеченное вручную проходит мимо фильтров — если человек отметил строку, он получит файл, даже из исключённой папки

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

Копия .uasset

Побайтовое копирование, ничего не загружается. Уровень тянет за собой свои OFPA-пакеты — без них выгруженная карта откроется пустой.

⚠️ С источника снимается атрибут read-only. В проектах под VCS, которая держит невытянутые файлы read-only (Perforce и подобные), Windows CopyFile переносит атрибут на копию. Без этого артист получал бы нередактируемую папку, а повторный прогон с политикой Overwrite падал бы на собственном предыдущем выводе. Флаг снимается с обеих сторон: перед перезаписью и после копирования.

Конвертация в обменные форматы

Через UAssetExportTask — меши в FBX, текстуры в PNG, звук в WAV. Загружает каждый ассет, поэтому счёт идёт на минуты, а не на секунды; GC каждые 64 ассета, иначе память кончится раньше экспорта.

Соответствие класса и расширения — в настройках (ExchangeFormatByClass), поиск идёт вверх по иерархии: запись Texture покрывает Texture2D. Пустая карта означает встроенные умолчания.

Blueprint в умолчаниях отсутствует намеренно. Обменного формата у него нет; запись породила бы .t3d-дамп с подписью «экспортировано». Вместо этого класс попадает в счётчик нет настроенного формата.

Причины пропуска разделены на три счётчика — нет формата / нет экспортёра / не загрузился. «17 пропущено» не говорит ничего; «17 без настроенного формата» ведёт прямо в нужную настройку.

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


Импорт из внешней папки

Window → Tools → Import Assets from Folder… — обратное направление: прочитать папку выгруженных .uasset и вернуть выбранное в проект.

Список с галочками, колонки: файл · во что превратится · откуда взят путь · статус.

Откуда берётся целевой путь

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

Четыре источника по убыванию доверия, и колонка показывает, какой сработал:

Источник Надёжность
Manifest Мы сами его написали из фактически записанного. Точно
Package header FPackageFileSummary::PackageName — имя, с которым файл сохраняли. Замерено на этом проекте: работает
Folder structure Позиция файла под корнем импорта. Верно для зеркальной выгрузки, догадка для прочих
Unresolved Не восстановить. Импортируется только в явно названную папку, ссылки не сработают

⚠️ Про Package header есть оговорка в самом движке (AssetHeaderPatcher.cpp:1214): поле сериализуется не всегда, и движок сам предусматривает откат. Поэтому цепочка, а не одна проверка.

Что импорт делать откажется

Импорт — единственная операция инструмента, способная уничтожить работу: запись поверх /Game/Art/SM_Rock заменяет то, что там было, без отмены и без копии.

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

После копирования выполняется ScanFilesSynchronous — без него файлы лежат на диске и невидимы в Content Browser, что читается как «импорт ничего не сделал».


Архитектура

AssetUsageAuditCore    UncookedOnly  — весь анализ, ноль UI
AssetUsageAuditEditor  Editor        — Slate-панель, настройки, команды
AssetUsageAuditTests   UncookedOnly  — 15 spec-сьютов, 159 кейсов

Инвариант Core: не линковать UnrealEd, AssetTools, ToolMenus, Slate, SlateCore, EditorSubsystem. AssetTools editor-only транзитивно через UnrealEd.

Engine в Core разрешён — Runtime-модуль, доступен в коммандлете. Нужен для ULevel::GetExternalActorsPaths.


⚠️ Инварианты, которые нельзя нарушать

Проверено чтением исходников UE 5.6. Без этого инструмент молча даёт неверный ответ.

1. Запрос зависимостей — всегда NoRequirements

AssetUsageAudit::MakeTraversalCategory()  // EDependencyCategory::Package
AssetUsageAudit::MakeTraversalQuery()     // FDependencyQuery{} — пустой

Два факта движка:

ExternalObjectAndActorDependencyGatherer.cpp:22 выдаёт рёбра карта→внешний актор с маской Game | Build — без Hard. А AssetRegistryInterface.h:95: отсутствие Hard и есть soft-зависимость. Запрос с EDependencyQuery::Hard теряет все 16 117 OFPA-пакетов проекта.

Soft определён как NotHard. Значит Hard | Soft = «требуется Hard И требуется не-Hard» = пустое множество.

Hard/soft — это колонка в отчёте, а не фильтр запроса. Отдельной константы Hard в коде нет — ошибиться негде.

Есть канарейка: если достижимых ассетов меньше четверти от числа OFPA-пакетов, в лог падает предупреждение. Это сигнатура регресса к Hard-запросу.

2. Границу карты пересекать только от уровня или его внешнего пакета

Первая версия проваливалась в любой встреченный World. Замер на WP_Example: 18 136 строк, из них 9 994 приходили через чужую карту:

WP_Example → BP_GameMode → PDA_MenuConfig → L_Other → …

Две трети ответа были содержимым другого уровня. После исправления — 8 644.

Правило: пересечение разрешено, только если источник ребра — сам уровень или его внешний пакет. Это покрывает сублевелы и Level Instance, но не ссылку из дата-ассета. Флаг bTraverseIntoOtherLevels возвращает прежнее поведение.

3. Пути OFPA не собирать строками

Только ULevel::GetExternalActorsPaths / GetExternalObjectsPaths (множественная форма — плагины регистрируют пути делегатами). Content Bundles вставляют /CB/<Guid>/, External Data Layers — /EDL/<UID>/.

Обратное отображение «внешний актор → его карта» не делать: PackageDependencyData.cpp:57-96 намеренно снимает флаг UsedInGame с этого ребра. Идти только вперёд от карты.

4. Blueprint не находится фильтром по классу

У ассета BP_Foo класс всегда /Script/Engine.Blueprint. Геймплейный класс живёт в теге GeneratedClass. Нужны два запроса — как в SAssetAuditBrowser::AddAssetsOfClass.

5. ScanLevelAssets перед обходом уровня

Гейтерер сообщает только те внешние пакеты, которые реестр уже отсканировал. Без этого уровень, который никто не открывал в сессии, отдаёт пустой список — неотличимо от уровня без акторов.


Проектные решения

Чекбоксы — TSet<FName>, не селекция SListView. План предписывал селекцию (Ctrl+A бесплатно), но она пересоздаётся при смене фильтра: пользователь, отметивший 40 ассетов и переключивший тип, потерял бы отметки молча.

Тип ассета и геймплейный класс — разные оси. Смешение давало 2980 записей в выпадающем списке. Разделено: 136 классов в меню, геймплейный класс — текстовым полем.

Смена области не запускает аудит. «All levels» = 1072 уровня; долгий прогон по клику в списке был бы неприятным сюрпризом. Результат помечается устаревшим.

Выгрузка — копирование, не MigratePackages. Migrate тянет всё дерево зависимостей, чего никто не просил, и требует editor-only модуля.

Конвертация живёт в Core, а не в Editor. UExporter и UAssetExportTask — это ENGINE_API в Runtime/Engine, не в UnrealEd. Core уже линкует Engine, так что инвариант не нарушен. Конкретные экспортёры (FBX, PNG) резолвятся рефлексией в рантайме: их отсутствие даёт честное «нет экспортёра», а не ошибку линковки — и открывает дорогу коммандлету.


Сборка и тесты

⚠️ Закрыть редактор — иначе линковка DLL упадёт.

"<UE>/Engine/Build/BatchFiles/Build.bat" \
  <Project>Editor Win64 Development \
  -project="<abs path>/<Project>.uproject" -waitmutex
"C:/Program Files/Epic Games/UE_5.6/Engine/Binaries/Win64/UnrealEditor-Cmd.exe" \
  "<project>.uproject" \
  -ExecCmds="Automation RunTests AssetUsageAudit" \
  -TestExit="Automation Test Queue Empty" \
  -unattended -nopause -nosplash -stdout -abslog="<abs path>"

159 кейсов в 15 spec-сьютах, префикс AssetUsageAudit.*.

Три набора работают на настоящем контенте проекта, а не на выдуманных именах: ExporterLive (копирование, коллизии, раскрытие OFPA), ExchangeExport (конвертация) и GraphFidelity (сверка графа с реестром). Субъект они ищут через Asset Registry и берут самый маленький OFPA-уровень — на WP_Example тест копировал бы гигабайты. Если контент не найден, тест пишет предупреждение и не падает, поэтому в логе стоит смотреть на предупреждения: их отсутствие означает, что тесты реально работали с контентом.

GraphFidelity — замена ручной сверке с Reference Viewer

Reference Viewer сам по себе не источник истины: он рисует те же рёбра, что отдаёт Asset Registry. Поэтому «сверить глазами десять ассетов» сведено к воспроизводимой проверке — граф против сырого ответа реестра тем же запросом:

  • ни одного выдуманного ребра и ни одного потерянного (кроме отфильтрованных по путям);
  • флаг hard совпадает с ответом реестра;
  • каждый переход в колонке Route — настоящее ребро, а не склейка;
  • ассет сублевела приписан и сублевелу, и родительской карте.

⚠️ Выборка обязана быть по имени пакета, а не по индексу. Индексы зависят от порядка перечисления реестра: граф между прогонами идентичен (84 505 пакетов, 370 722 ребра), а шаг по индексу каждый раз брал разные пакеты, и спека падала через раз. Сейчас проверяется одно и то же: 872 пакета и 3393 цели, число в число во всех прогонах.

⚠️ Hard сравнивать по цели, а не по ребру. Реестр может отдать одну и ту же цель дважды с разными масками — hard и soft одновременно, — и граф честно хранит оба. Проверка «это ребро hard?» против множества «есть ли hard-ребро к этой цели» помечает soft-двойника расхождением.

⚠️ Два флага ломают прогон на этом проекте

Флаг Симптом
-nullrhi Fatal error: Null assigned to TNotNull (NotNull.cpp:12). Дефект проекта, не плагина — воспроизводится при полностью отключённых плагинах
-NoShaderCompile Failed to find shader map for default material (MaterialShared.cpp:2905)

Следствие: headless без GPU на этом проекте недоступен. Это блокирует серверную автоматизацию.

Ещё: тест, намеренно провоцирующий UE_LOG(Error), обязан объявить AddExpectedError — иначе фреймворк считает его упавшим.


Замеры

Метрика Значение
Пакетов в графе 62 517 (после исключений; без них — 84 505)
Рёбер 290 170 (без исключений — 370 722)
Уровней 1072
OFPA-пакетов 16 117
Полный свип ~1.1 с
WP_Example ~1.6 с, 8 644 ассета
Все уровни 33 261 UsedOnLevel
Отчёт 45 167 строк, JSON 21.8 МБ + CSV 10.2 МБ

Кэш не нужен — вопрос закрыт замером.


Незакрытое

  • UI после переделки в дерево руками не проверялся. Компилируется, но автотесты не трогают Slate: раскрытие уровней, третье состояние галочки на заголовке и сортировка внутри группы проверены только чтением кода.
  • Коммандлет не написан. Упирается в дефект headless выше.
  • Конвертация не прогонялась на большом объёме. Тесты покрывают единицы ассетов; поведение GC и времени на тысячах — не замерено.
  • Карта форматов узкая. В дефолте только классы, для которых движок реально поставляет UExporter. Материалы, блюпринты и Niagara не конвертируются никуда — это ограничение движка, а не недоделка, но пользователя нужно об этом предупредить явно.
  • GameMode может давать заметную долю строк отчёта по главной карте — нужен ли фильтр «исключить достижимое только через GameMode», решать по месту.
  • Ассеты сторонних аудио-плагинов попадают в Unknown — скрывать по умолчанию или помечать, решать по месту.

Контроль версий

Плагин — самостоятельный git-репозиторий. Артефакты сборки (Binaries/, Intermediate/) исключены локальным .gitignore: они пересобираются UBT из исходников и в коммит попадать не должны.

При подключении к проекту как сабмодуль:

git submodule add <url> Plugins/AssetUsageAudit

Связанное

  • Plugins/AssetsCleaner — ищет неиспользуемые ассеты, но не умеет привязку к уровням
S
Description
UE5.6 editor plugin: asset usage audit, export/import, automation specs.
Readme 740 KiB
Languages
C++ 98.6%
C 0.8%
C# 0.6%