Files
Faset_Engine/docs/studies/09-unity-ux-extensibility-study.md

39 KiB
Raw Permalink Blame History

Unity: UX редактора и расширяемость для собственного движка

Синхронизация Faset, 18.09.2026. Принятые решения — ARCHITECTURE.md, этапы до/после MVP — PLAN.md. Ниже сохранено исследование чужих исходников; это не отчёт о реализованных возможностях Faset. Рекомендации, помеченные superseded, остаются только историей рассмотренных вариантов.

Faset выбрал C++/EnTT, собственный retained C++ UI с декларативными layout/styles и тёмной темой по умолчанию; ImGui остаётся debug tool. UI Toolkit, C# assemblies и Unity lifecycle здесь — изученные образцы, не выбранные зависимости. Явная schema с TypeId/FieldId обслуживает Inspector и JSON; gameplay статически включается в отдельный Player, editor plugins — DLL/SO под точный SDK. MCP ограничен editor authoring/import/build/PlayStop/logs, не читает/меняет runtime world и отсутствует в Player/экспорте.

Дата проверки: 17 сентября 2026. Исследован официальный UnityCsReference, shallow clone в ../../../UnityCsReference. Зафиксирован commit 6b50e5544f6efcca1f44dbace3d1778b465ac6d0 от 2026-09-03, версия в README — Unity 6000.7.0a6. Это alpha-снимок C# reference source; его API нельзя автоматически считать контрактом каждой выпущенной Unity 6.

UnityCsReference содержит C# части движка и редактора, но не полную native-реализацию Unity. README определяет reference-only использование; это не свободная библиотека для включения в свой движок. Здесь рассматриваются архитектурные идеи и наблюдаемое поведение. В каждом разделе различаются реально прочитанные C# тела, объявления native bindings и предлагаемый собственный дизайн. Основание: README.md:1 и официальная страница репозитория.

Дополнительно проверены актуальные страницы официальной документации. Их current-адреса при проверке показывали Unity 6.5/6.6; они обновляются независимо от alpha-снимка. Unity Editor не запускался, измерений времени и пользовательских экспериментов нет. Все приёмочные сценарии ниже — предложения для будущей реализации, а не пройденные тесты Unity. Локальных AGENTS.md и graphify-графа в клоне не найдено; прочитан локальный AGENTS.md.

Главная находка: удобство Unity строится на общих контрактах редактирования. Inspector, gizmo, importer и расширение не должны каждый заново изобретать сохранение, Undo, выделение и обновление интерфейса. Для своего редактора это полезнее буквального повторения расположения его панелей.

1. Единый путь изменения свойства: multi-edit, Undo и prefab overrides

Сценарий пользователя. Выделить двадцать источников света, изменить дальность, отменить одним действием, затем у одного экземпляра prefab вернуть только дальность к исходному значению. Сильный UX здесь — сохранение смысла операции независимо от того, выполнили её стандартным полем, пользовательским Inspector или инструментом сцены.

Что подтверждено. SerializedObject создаётся для одного либо нескольких targets; FindProperty возвращает property по пути и удерживает owning object живым. Но ApplyModifiedProperties, Update, UpdateIfRequiredOrScriptextern: внутреннюю сериализацию и diff этого репозитория мы не видим. SerializedObject.bindings.cs:23, поиск — 77, native boundary — 122139. Аналогично Undo.RecordObject привязан к native RecordUndoDiff, а не содержит C# diff-алгоритм: Undo.bindings.cs:184.

Зато orchestration доступна. При изменении UI-поля binding записывает property, применяет изменения, обновляет revision и объединяет Undo-операции. SerializedObjectBindingPropertyToBaseField.cs:35, SerializedObjectBindingToBaseField.cs:100. При синхронизации смешанных значений используется присваивание без уведомления: иначе само отображение могло бы записать значение первого объекта всем остальным (PropertyToBaseField.cs:28).

RevertPropertyOverride снимает флаг override и применяет serialized changes; ветки различают пользовательскую операцию с Undo и автоматическую без Undo. ApplyPropertyOverride предварительно проверяет несовпадения массивов, managed references и недопустимые scene references, затем вызывает native implementation. PrefabUtility.cs:658, revert — 922.

Что перенять. Ввести PropertyHandle со стабильными object/type/property IDs, состояниями value/mixed/unavailable и единый edit transaction. Его commit должен обслуживать Undo, dirty state, изменение prefab delta и уведомления UI. Перетаскивание slider — одна транзакция; отмена возвращает каждое исходное значение. Для первого прототипа достаточно scalar/vector/color/reference, нескольких targets и плоских overrides. В Faset MVP вложенные экземпляры адресуются постоянной instance chain/ObjectId/ComponentId/FieldId; массив заменяется целиком. Variant inheritance и Apply to template — после MVP. Reparent ограничен одним экземпляром; удаление унаследованного объекта — suppression с диагностикой оставшихся ссылок.

Ловушки. Unity предупреждает: разные serialized streams нужно синхронизировать; запись через serialized property обходит обычный setter; чтение возвращает значение первого target, тогда как запись меняет все. Поэтому validation должно существовать на уровне данных. SerializedObject API. При прямой правке prefab instance отдельная запись prefab modifications тоже существенна; официальный API рекомендует serialized workflow. Prefab modifications.

Приёмка. Mixed selection без действий не меняет данные. Drag создаёт один Undo. Undo/Redo восстанавливает разные прежние значения двадцати объектов. Два открытых Inspector сходятся после commit. Revert одного поля не сбрасывает остальные overrides. Сохранить/переоткрыть сцену — результат тот же. Невалидный reference отклоняется с объяснением до частичного изменения документа.

2. UI Toolkit: декларативный Inspector поверх общей модели данных

Сценарий пользователя. Автор компонента добавляет один удобный редактор диапазона или списка точек; стандартные поля, сторонняя панель и инструмент сцены продолжают показывать согласованные данные. Новое поле компонента остаётся доступным даже до написания специализированного UI.

Что подтверждено. InspectorElement.FillDefaultInspector обходит visible serialized properties и создаёт PropertyField; специализированный путь вызывает Editor.CreateInspectorGUI(). Это реальные тела: InspectorElement.cs:628, custom path — 703. То есть базовый редактор выводится из schema, а специальный UI подключается расширением.

SerializedObjectBindingContext.BindTree проходит дерево элементов, разрешает bindingPath относительно родителя и продолжает привязку детей. При превышении внутреннего временного порога остаток откладывается на следующий кадр. SerializedObjectBindingContext.cs:144, порог/решение — 99–106. Обновление serialized object ограничивается одним проходом на кадр данного updater; revision/change tracker сообщает, когда обновлять связанное представление: 651, 683. Это не доказательство полного отсутствия polling: вызов PollForChanges явно присутствует.

Официальный workflow объединяет CustomEditor, CreateInspectorGUI, visual tree/UXML, PropertyField и property drawers; для вложенного plain serializable type рекомендуется drawer. Custom Inspector. SerializedObject binding — отдельный редакторский механизм; его не следует смешивать с любым runtime binding UI Toolkit. Binding manual.

Что перенять. Стандартный Inspector из schema должен работать первым. Поверх него — registry визуальных редакторов типов и отдельных свойств, общий binding adapter и возможность заменить участок, сохранив default fields вокруг. В начальной версии достаточно дерева widgets и стилей; XML-подобный язык и визуальный UI Builder не обязательны. Версии данных, no-notify assignment и корректный unbind важнее собственного markup. Для больших списков понадобятся virtualization и отложенное создание дорогих controls; конкретный Unity-порог 50 ms не стоит принимать за целевой frame budget своего UI.

Ловушки. Переименование property ломает строковые пути; исчезновение target и смена типа инвалидируют binding. Программное обновление UI может рекурсивно породить запись данных. Custom Inspector способен скрыть новые поля и потерять multi-edit, если обходит общий слой. Недостаточно красиво отрисовать поле — нужно сохранить keyboard focus, numeric dragging, mixed state и семантику Undo.

Приёмка. Добавить новое сериализуемое поле без редакторского кода. Написать custom drawer один раз и увидеть его в массиве, Inspector и popup. Удалить выделенный объект при открытых панелях — без stale callbacks. Менять одно поле извне — обновляются нужные controls, курсор в другом поле не прыгает. На большом документе измерять задержку открытия и input latency, а не только число UI-элементов.

3. Contextual tools и overlays: расширение как часть рабочего пространства

Сценарий пользователя. Выбрать spline и получить инструмент перемещения узлов рядом со сценой; выбрать обычный mesh — вернуться к подходящим инструментам. Настройки кисти можно закрепить, свернуть, перенести и восстановить после перезапуска.

Что подтверждено. EditorTool.targets использует связанные targets для component tool, иначе текущее Selection.objects. В классе существуют lifecycle hooks, OnToolGUI, IsAvailable, toolbar icon и grid snapping. Activation/deactivation защищены от повторного вызова: EditorTool.cs:57, 92. EditorToolManager запрещает рекурсивную смену tool внутри перехода, вызывает deactivate предыдущего и activate нового, запоминает предыдущий builtin/custom tool, затем рассылает события. EditorToolManager.cs:238. Различие global/component tools подтверждает официальный EditorTool API.

Overlay отделяет содержимое инструмента от размещения. ToolbarOverlay строит toolbar из идентификаторов элементов; OverlayCanvas.SaveData сохраняет ID, container, положение, visibility и сериализованное содержимое. Восстановление ищет container, имеет fallback, сортирует элементы по сохранённому индексу и dock-ит их. ToolbarOverlay.cs:18, OverlayCanvas.cs:104, 1408, 1457. Публичная регистрация panel/toolbar через атрибуты описана в Create your own overlay.

Что перенять. Сделать descriptor инструмента: stable ID, название/icon/shortcut, target predicate, viewport handlers, optional settings view и lifecycle. Selection service принадлежит редактору, не конкретной панели. Tool manager управляет input capture и переходами; layout service управляет размещением. Сначала достаточно одной полосы contextual tools и одной перемещаемой панели. Обязательно сразу предусмотреть ручное скрытие, понятное disabled-state объяснение и возврат к предыдущему инструменту.

Ловушки. Нельзя хранить вечные raw pointers на выделенные объекты. Несколько Scene Views требуют window context. Закрытие панели не равнозначно завершению drag; уничтожение selection должно безопасно завершать транзакцию. Потерянный plugin или изменившийся ID не должен ломать сохранённый layout. Пустые панели всех установленных расширений одновременно создают визуальный шум.

Приёмка. Включить spline tool, начать drag, сменить selection и закрыть viewport: input capture освобождён, Undo корректен. Работать с двумя viewport без переноса настроек камеры между ними. Сохранить layout, временно отключить plugin, снова включить — редактор открывается и восстанавливает допустимое размещение. Shortcut конфликтует явно, а недоступный tool не молча проглатывает клавишу.

4. Importer как воспроизводимая операция с зависимостями и диагностикой

Сценарий пользователя. Изменить внешний файл модели или настройки её обработки, вернуться в редактор и получить обновлённый asset без ручного удаления кэша; ссылки на его материалы сохраняются. При ошибке пользователь видит причину и исходный файл.

Что подтверждено. C# ScriptedImporter.GenerateAssetData вызывает OnImportAsset. Регистрация обнаруживает атрибуты, проверяет base class, нормализует extensions, отклоняет конфликтующие автоматически выбранные обработчики и передаёт version/priority/cache flag в asset pipeline. ScriptedImporter.cs:25, регистрация — 61, conflict — 103117, native registration — 133135.

AssetImportContext предоставляет outputs с identifiers, main object и отдельные зависимости на source/artifact. C# проверяет аргументы; хранение результатов и dependency graph скрыты за extern. AssetImportContext.bindings.cs:51, source dependency — 69, artifact dependency — 156. AssetDatabase.GetAssetDependencyHash и RegisterCustomDependency также native declarations, не доказательство конкретного алгоритма content-addressed storage: AssetDatabase.bindings.cs:670, 1287.

Документация требует детерминированности и стабильных output IDs, объясняет регистрацию зависимостей; без этого кэш может возвращать неверные результаты. Scripted Importers. Static dependencies включают importer version и target platform; dynamic dependencies выясняются при импорте. Refresh может перезапускаться из-за созданных файлов и новых import requests. Asset Database refresh.

Что перенять. Контракт Import(inputs, settings, target, context) → artifacts + diagnostics + dependencies, stable source GUID и local output IDs. Моя адаптация: cache key из source/dependency hashes, версии importer и settings/target; прозрачная команда «почему переимпортирован». Первую версию сделать локальной и однопроцессной, с atomic publication успешного результата. Distributed cache и parallel workers требуют отдельной проверки thread/process safety и не нужны для доказательства полезности UX.

Ловушки. Чтение незаявленного файла, времени, random seed или machine path делает результат невоспроизводимым. Нестабильный ID выходного mesh ломает scene references после reimport. Изменение исходников самим importer может запустить цикл. Ошибка обработки не должна уничтожать последний рабочий asset; это предлагаемое правило собственного движка, не установленный здесь контракт Unity.

Приёмка. Дважды импортировать одинаковые inputs на чистом кэше: одинаковые artifacts. Изменить config dependency — переработаны только зависимые assets. Переименовать source при сохранении GUID — ссылки выживают. Повысить importer version — cache invalidated. Убить worker посередине — нет полузаписанного результата. При сломанном файле видны path, importer, dependency и actionable error; повторное исправление восстанавливает asset автоматически.

5. Модули и registries: расширение устанавливается без правок ядра

Сценарий пользователя. Команда добавляет пакет «дороги»: runtime component, importer формата дорог, Inspector, Scene tool и тесты. Сборка игры не тащит редакторские окна; отсутствие optional render pipeline объясняется зависимостью пакета.

Что подтверждено. CustomScriptAssemblyData задаёт references, platform filters, define constraints, version defines и no-engine references. FromJson запускает validation; несовместимые include/exclude отклоняются. IsCompatibleWith проверяет build/editor/test context, define constraints и platforms. Реальные C# тела: CustomScriptAssembly.cs:73, parser/validation — 101132, compatibility — 541. Это подтверждение модели модулей, а не доказательство мгновенной пересборки или безопасного hot-unload любого plugin.

Editor extension discovery тоже открыто: CustomEditorAttributes.Initialize очищает registry, ищет типы с CustomEditor, проверяет наследование, валидирует настройки и добавляет editors в cache. CustomEditorAttributes.cs:169. Аналогичный подход уже виден у importer. В итоге пользовательская функция подключается в существующий интерфейс через contract и metadata, а не fork основного editor.

Актуальный manual требует asmdef для кода UPM-пакета и разделяет Editor/Runtime/Tests; runtime не должен ссылаться на Editor. Package assembly definitions. Организация assemblies служит границам зависимостей и итерации компиляции. Assembly introduction.

Что перенять. Manifest с ID/version/dependencies/capabilities и отдельные runtime/editor modules. Extension registry должен предлагать typed entrypoints для Inspector, tool, importer, menu и diagnostics. На раннем этапе явная регистрация через API проще reflection scanning. Все регистрации принадлежат одному plugin scope; отключение снимает hooks и UI. Заранее определить совместимость schema/API versions и сообщения о conflicting IDs.

Ловушки. Dependency cycle и Editor→Runtime допустимая зависимость не означают обратную допустимость. Optional integration не должна становиться безусловным dependency. Не обещать live unloading, пока код может удерживать delegates, task callbacks или GPU resources. Registry discovery может быть быстрым, но тяжёлый plugin initialization всё равно блокирует пользователя; его нужно измерять и откладывать.

Приёмка. Установить локальный roads package без изменения editor source. Построить standalone runtime без Editor symbols. Конфликт двух importer/tool IDs даёт конкретное сообщение. Отсутствующая optional dependency скрывает только соответствующую интеграцию. Отключить пакет — не остаётся меню, listeners и скрытых объектов; связанные документы сохраняют диагностируемые unknown component data, если такая политика выбрана.

6. Быстрый Play требует явных границ сессии и очистки состояния

Сценарий пользователя. Многократно менять параметр, нажимать Play и останавливаться без долгого рестарта и без того, чтобы второй запуск отличался от первого из-за забытых static fields или двойных подписок.

Что подтверждено. EnterPlayModeOptions отдельно содержит DisableDomainReload и DisableSceneReload; сами настройки — native bindings. EditorSettings.bindings.cs:67, 274. Это два разных механизма: по enum нельзя восстановить полный native порядок сохранения сцен и перезагрузки runtime.

В данном свежем снимке доступна более интересная C# часть: PlayModeScope.Enter исполняет lifecycle methods по порядку, а Exit — в обратном. PlayModeScope.cs:20. ScopeTransitionHelper вызывает зарегистрированные callbacks, оборачивает их в profiling markers и обрабатывает исключения: ScopeTransitionHelper.cs:65, обратный проход — 115–152. Native интеграция обозначена явными RequiredByNativeCode входами: DomainReloadLifecycleController.cs:70.

Текущая документация описывает сохранение static state/events при выключенном domain reload, ручные OnEnteringPlayMode/OnExitingPlayMode и code-generated AutoStaticsCleanup/NoAutoStaticsCleanup. Она также различает field initializer и static constructor: очистка не означает повторный запуск всего конструктора типа. Domain reload manual. Это сведения конкретной текущей документации; переносить эти API и defaults на старые Unity нельзя. Декларации cleanup attributes доступны в StaticsCleanupAttributes.cs:31, но это само по себе не тело source generator.

Что перенять. Разделить persistent editor state, project/document state и play-session state. Создавать simulation scope с явными start/stop hooks, владением subscriptions/tasks/resources и детерминированной очисткой. Для Faset принят отдельный Player со snapshot authoring-сцены; дальнейшие правки документа попадут в новый запуск. Прежние варианты in-process world/snapshot restore и live Apply superseded для MVP. После надёжного старта/остановки можно оптимизировать безопасные caches; runtime changes не становятся authoring Undo.

Ловушки. Static event удерживает старый scene object; background task завершится уже в другой play session; отключение scene reload меняет ожидания component lifecycle. Пропуск полной перезагрузки даёт скорость только при полном контракте reset. Profile start/stop отдельно от compilation/import: иначе оптимизируется не главный источник ожидания.

Приёмка. Сто циклов Play/Stop: одинаковое стартовое состояние, стабильное число подписок и отсутствие постоянного роста памяти. Остановить игру во время загрузки asset — поздний callback не меняет редакторскую сцену. Exception одного cleanup hook виден и не оставляет input/audio активными. Изменение runtime transform исчезает после Stop, а независимая authoring-транзакция остаётся в документе; обратный Apply из Player в MVP отсутствует. Сравнить cold start и fast start на одной сцене, без обещаний ускорения до измерения.

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

Первым строить identity/schema, edit transactions, multi-selection и Undo: на них опираются почти все остальные средства. Вторым — default Inspector и один typed extension registry. Третьим — import context с зависимостями и сохранение документов. Затем contextual tools, пользовательский layout и модульные пакеты. Ускоренный Play вводить после определения состояния, которое обязано переживать или завершать сессию.

Для вертикального прототипа достаточно одного собственного компонента «дорога»: импорт данных, поле ширины с multi-edit, точки с gizmo, одна overlay, сохранение prefab override и Play-проезд камеры. Такой сценарий проверит взаимную работу контрактов. Копия всего UI Unity, собственный marketplace и универсальный UI Builder на этой стадии не проверяют главные архитектурные решения. Итог этого исследования — требования и проверяемые механизмы; код нового движка здесь не создавался.

Под требования Linux/Windows, 2D/3D и равного удобства ручной работы и MCP эти идеи адаптируются через общий document API. Действие из Inspector и MCP-команда должны обращаться к одной validation/transaction/schema модели, возвращать одинаковые изменения и понятный результат; UI остаётся представлением, а не единственным способом вызвать функцию. Runtime tick при этом не обязан проходить через редакторский Undo. Интеграция Blender должна пользоваться stable asset IDs, importer dependencies и общей диагностикой: повторный экспорт с устойчивыми source/output IDs обновляет прежний asset и сохраняет совместимые scene overrides; исчезнувшая цель требует явного разрешения конфликта. Обычный glTF/GLB import не требует add-on; optional Python add-on работает в официальном Blender и добавляет IDs/кнопку экспорта. Без IDs matching после rename не гарантируется. Это требования к нашему будущему движку, а не утверждение о наличии такого MCP/Blender контракта в изученном Unity-коде. В приёмке одного сценария дороги нужно сравнить ручное создание, MCP-создание и повторный импорт из Blender по семантическому результату, включая Undo и сохранение ссылок.