С появлением Flutter GPU у Dart-разработчиков впервые появился прямой доступ к Metal и Vulkan без единой строчки нативного кода: буферы, текстуры, рендер-пассы и заранее скомпилированные шейдеры доступны из обычного Dart-пакета. Возможности выглядят заманчиво, но у любого нового графического API настоящие границы видны только под нагрузкой. Простой вращающийся кубик из примеров ничего не говорит о том, переживёт ли API карты теней с шестью источниками, HDR-конвейер с bloom или сцену с сотней материалов.
Поэтому я решил проверить Flutter GPU самым честным из известных мне способов: написать над ним полноценный 3D-движок и довести его до трёх играбельных игр. Движок называется flutter3d, он написан на чистом Dart (включая физику, коллизии и аудио-микшер), опубликован под MIT в виде 23 пакетов на pub.dev, а игры можно запустить прямо в браузере. Для меня это, к слову, четвёртый движок после ассемблерного (в 90-е на x86), движка на Java и на C++ и первый, где вопрос «а можно ли на этом сделать настоящую 3D-игру?» задавали не мне студенты моих курсов, а я сам - новому API.
В этой статье я разберу, что Flutter GPU умеет, а чего от него ждать не стоит; как спроектировать движок так, чтобы ограничения чужого API не стали ограничениями вашей архитектуры (спойлер: слой аппаратной абстракции с тремя взаимозаменяемыми бэкендами - Flutter GPU, WebGL2 и программным растеризатором); как устроены сцена, освещение, карты теней и сборка кадра; и какие грабли по дороге оказались самыми дорогими.
Что даёт и чего не даёт Flutter GPU?
Начну с фактов, которые определили всю дальнейшую архитектуру. Flutter GPU (API движка Impeller, доступный из Dart начиная со стабильного Flutter 3.47 на нативных платформах) предоставляет буферы геометрии, текстуры, командные буферы и рендер-пассы, а шейдеры компилируются на этапе сборки в бандл через flutter_gpu_shaders. Вместе с AOT-компиляцией и SIMD-типами Dart этого достаточно, чтобы всерьёз говорить о рендерере на чистом Dart.
Теперь ограничения, с которыми предстоит жить (актуальны для Flutter 3.47):
рантайм-компиляции шейдеров нет: весь набор шейдеров должен быть известен на этапе сборки. Для движка это означает, что произвольные материалы «как в Unity» невозможны, зато весь набор пайплайнов известен заранее и проверяется тестом;
depth-текстуру нельзя сэмплировать: карту теней приходится писать в цветовую текстуру. Ниже покажу, что при ортографической камере теневого пасса это даже удобно;
один рендер-пасс на командный буфер: конвейер кадра (тени, небо, сцена, пост-обработка) складывается из последовательности буферов, и планировщик пассов становится обязательной частью движка;
stale bindings при смене пайплайна: RenderPass копит привязки uniform-блоков и текстур в хеш-таблице с ключом «структура шейдера» и при каждом draw применяет все накопленные, включая устаревшие от других шейдеров. При смешении пайплайнов (например, skinned- и статичных мешей) чужая привязка попадает на слот текущего пайплайна, и кто победит - решает порядок итерации хеш-таблицы, стабильный внутри запуска, но разный между запусками. Симптом выглядит мистически: skinned-модель рисуется «щепкой», объект уезжает в чужую позицию, а минимальный репро из одного пайплайна невинен. Обход в flutter3d: очистка привязок перед каждой сменой пайплайна, закреплённая регрессионным тестом;
на Impeller uniform-блок может просто не дойти до конкретного пайплайна: в моём случае блоки не доходили до шейдера неба, хотя привязывались тем же вызовом, что у всех остальных стадий, и рефлексия сообщала правильные размеры и смещения. Небо в итоге получает данные через вершинные атрибуты. Это поведение измерено на записанных кадрах, а не предположено.
Список выглядит пугающе, но у него есть важное свойство: все пункты обходятся, и ни один из них не просочился в публичный API движка.
Страховка от чужого API
Главное архитектурное решение flutter3d выросло ровно из этого списка: рендерер не обращается к графическому API напрямую, а работает только со слоем аппаратной абстракции (HAL). Слой оформлен как отдельный пакет flutter3d_hardware: интерфейсы GraphicsDevice, буферов, текстур, дескрипторов рендер-пассов и энкодеров команд. Рендерер, теневые пассы и цепочка пост-обработки написаны к этим интерфейсам и не знают, что находится под ними, поэтому каждая особенность Flutter GPU из списка выше локализована внутри одного пакета-реализации и не протекает в остальные двадцать два.
У такой изоляции есть побочный эффект, который быстро стал главной ценностью: если рендерер не знает, на чём рисует, то бэкендов может быть сколько угодно. Сейчас их три:
flutter3d_impeller- основной бэкенд поверх Flutter GPU (Metal на Apple-платформах, Vulkan на Android);flutter3d_webgl- реализация на WebGL2 для браузера (Flutter GPU в вебе недоступен, а игры в браузере были обязательным требованием - именно там их можно показать без установки);flutter3d_cpu- программный растеризатор на чистом Dart, без GPU, драйвера и шейдерного языка (зачем он нужен - расскажу позже).
Приложению не нужно выбирать бэкенд вручную: пакет flutter3d_app через conditional import подключает Impeller на нативных платформах и WebGL2 в вебе. Обратите внимание: HAL здесь - не внутренний шов, а задокументированный публичный контракт с собственным набором conformance-тестов, поэтому бэкенд под платформу, которой ещё нет в списке (WebGPU напрашивается), можно написать снаружи, не форкая движок. К контракту я ещё вернусь.

Как устроена сцена?
При проектировании сцены я хотел совместить два требования, которые обычно конфликтуют: привычный API с деревом нод (как в three.js или Unity) и кадр, который это дерево не обходит. Снаружи всё выглядит классически: MeshNode, CameraNode и LightNode наследуют SceneNode с трансформацией, иерархией, видимостью и маской слоёв. Внутри же Scene при каждом attach/detach поддерживает плоские реестры, поэтому frustum culling и построение списка отрисовки выполняются линейным проходом по непрерывному массиву (что заметно дружелюбнее к сборщику мусора Dart, чем прыжки по дереву), а метод traverse остаётся для прикладного кода.
Вторая особенность - отказ от dirty-флагов в пользу версий. Каждая нода хранит собственную версию трансформации и запомненную версию родителя; геттер worldMatrix сравнивает их и пересчитывает матрицу при расхождении, рекурсивно вверх по иерархии. Таким образом, отдельного update-пасса в движке нет, и забыть его вызвать невозможно: устаревшая мировая матрица становится состоянием, которое нельзя выразить (кто ловил кадр задержки из-за пропущенного updateMatrixWorld в three.js, оценит). Дополнительно глобальный счётчик worldVersion инвалидирует всё производное: мировые границы для culling, матрицу вида камеры, инверсию для нормалей.
Камера при этом - обычная нода и подчиняется общим правилам иерархии: камера в кабине машины оформляется как дочерняя нода кузова, а контроллеры (orbit, fly) двигают ноду, а не камеру. Из этого следует приятное свойство: тем же orbit-контроллером можно вращать вокруг модели, например, источник света.
Завершает картину структура RenderView: камера, доля вьюпорта, маска слоёв, приоритет, настройки очистки и спецификация таргета. Сплит-скрин, миникарта, рендер в текстуру и теневые пассы в движке не являются отдельными механизмами - это разные наборы полей одной структуры (камера теневого пасса направленного света в буквальном смысле ещё один RenderView).
Как работает освещение?
Моделей освещения шесть: Unlit, Lambert, Blinn-Phong, PBR на GGX, Toon и Normals. Каждая модель скомпилирована в отдельный фрагментный шейдер заранее - здесь ограничение Flutter GPU на рантайм-шейдеры превращается в дисциплину: весь набор известен на этапе сборки и закреплён тестом. Смена модели означает смену пайплайна, поэтому рендерер кэширует пайплайны по имени шейдера и сортирует вызовы отрисовки в первую очередь по нему.
Важная деталь: LightingModel - это value-класс, а не enum, поэтому список моделей не закрыт. Приложение, которое собирает собственный шейдерный бандл, может добавить свою модель освещения, и рендерер закэширует под неё пайплайн наравне со встроенными.
Источники света поддерживаются трёх типов (directional, point, spot) с затуханием по inverse-square из спецификации glTF. До восьми источников пакуются в uniform-массивы vec4[8] со счётчиком в uniform-переменной, поэтому включение и выключение света не пересобирает пайплайн: когда в шутере гаснет факел, меняется одно число.
Поддерживается и image-based lighting: префильтрованная specular-цепочка плюс diffuse-уровень, собираемые из кубмапы или из настроек неба. Здесь стоит отметить неочевидную деталь: в свёртке нет ни одного случайного числа, выборка идёт по фиксированной спирали золотого угла. Сделано это не ради эстетики: только полностью детерминированная картинка позволяет попиксельно сравнивать два независимо написанных бэкенда, а такое сравнение, как я покажу ниже, является главным инструментом контроля качества всего проекта.
Как устроены тени?
Для направленного света используются каскадные карты теней: три каскада лежат бок о бок в одном атласе, сплит логарифмический, дистанция по умолчанию 60 метров. Каждый каскад вписывает сферу вдоль оси взгляда и привязывается к собственной текстурной сетке (без этой привязки тень заметно «плывёт» при движении камеры). Глубина пишется в цветовую текстуру - то самое ограничение Flutter GPU на сэмплирование depth-текстур; ортографическая камера теневого пасса делает gl_FragCoord.z линейной величиной, которую можно корректно сравнивать, так что цветовой таргет здесь полноценная замена. Сверху добавлены front-face culling, PCF 3×3, depth bias и normal offset.
У точечных источников тени кубические, и в их устройстве есть решение, которое мне до сих пор нравится: грани куба хранят радиальную дистанцию вместо глубины. Дело в том, что глубина у каждой грани своя, и на каждом ребре куба возникал бы шов, а радиальная дистанция непрерывна по построению. При выборке шейдер получает те же шесть матриц, которыми грани рисовались, поэтому второго вывода, способного разойтись с первым, в системе просто нет.
Атласов у каждого света два: статичный и динамичный. Стены подземелья не двигаются, поэтому статичная половина рендерится один раз при загрузке уровня, а шейдер берёт ближайший occluder из обеих. Одновременно тени отбрасывают шесть источников - число взято из практики: в стартовом склепе демо-шутера висят шесть факелов, и при лимите в четыре строки атласа два факела освещали свой угол, но не отбрасывали теней, причём какие именно два - менялось по мере движения игрока. С точки зрения игрока два одинаковых факела с разным поведением выглядят как сломанные тени, и по существу это верная оценка.
Тени же принесли проекту три самых поучительных бага, и о них стоит рассказать подробнее.
Атлас на 402 мегабайта
Гоночная демо-игра несколько недель выдавала меньше одного кадра в секунду, и профилирование выглядело тупиком: уменьшение разрешения кадра вдвое и вчетверо не меняло время кадра вообще. Это противоречие и оказалось ключом: если стоимость не зависит от числа пикселей, значит, она не в пикселях. Виновник нашёлся в аллокаторе текстур: атлас кубических теней брал разрешение из настроек теней солнца, и при значении, разумном для направленного света, один только атлас занимал 402 МБ видеопамяти - на платформе, у которой столько нет, начинается постоянное вытеснение текстур. После введения отдельной настройки ShadowSettings.cubeResolution (по умолчанию 512 - примерно сантиметр на тексель в двух метрах от факела, что мельче полутени, через которую тень сэмплируется) атлас сжался вчетверо, и игра поехала с нормальной скоростью.
Перевёрнутый атлас, который проходил все тесты
Теневой атлас шесть рабочих сессий подряд хранился в текстуре вверх ногами, и все тесты при этом проходили. Разгадка оказалась поучительной: каждая проверка атласа смотрела на него через полноэкранный композитный пасс, а этот пасс на бэкенде с origin в левом нижнем углу переворачивает свой вход. Перевёрнутая дважды картинка возвращалась правильной и сходилась с референсом до пикселя. Боевой lit-пасс при этом сэмплировал ту же текстуру напрямую, второго переворота не имел и читал строки, в которые никто не рисовал. Вывод, который теперь записан в документации проекта: инструмент, компенсирующий ровно ту ошибку, на которую его направили, согласится с чем угодно, поэтому прежде чем верить картинке, нужно спросить, что делает с ней тракт отображения.
Normal offset в неправильных единицах
Классический приём борьбы с self-shadowing - сдвиг точки выборки вдоль нормали - содержит неочевидный вопрос: в каких единицах задавать сдвиг? Тексель карты теней записывает весь накрытый им участок поверхности с одной дистанции, а размер этого участка растёт с расстоянием (тексель по сути является телесным углом). Поэтому смещение, заданное в метрах, оказывается верным на одной дистанции и неверным на всех остальных: двух сантиметров хватало возле лампы, и тех же двух сантиметров было втрое мало полу в десяти метрах от неё. Выглядело это как пол, затеняющий сам себя, причём артефакт обрывался идеально прямой линией - она и была подсказкой, поскольку в сцене из кривых поверхностей прямую линию не образует ничто, кроме границы содержимого самой карты теней. Теперь смещение задаётся в текселях и пересчитывается по дистанции.

Как собирается кадр?
Требование к горячему циклу кадра формулируется коротко: ни обхода дерева, ни аллокаций. Кадр - это линейный проход по реестру, frustum culling по кэшированным мировым сферам, сортировка и отрисовка; из-за ограничения «один пасс на командный буфер» последовательность пассов (тени, небо, сцена, пост-обработка) собирает планировщик, владеющий и рендер-таргетами. Для представления о производительности: десктопный шутер с шестью тенекастящими факелами, PBR-материалами, bloom и позиционным звуком держит 120 fps в debug-сборке на ноутбуке.
Вызовы отрисовки сортируются по упакованному в биты ключу, где старшие разряды занимает идентификатор пайплайна: смена пайплайна внутри пасса - самая дорогая смена состояния, поэтому сортировка по нему определяет структуру кадра, а не служит косметической оптимизацией. У ключа прозрачных объектов поля состояния нет вовсе: для корректности прозрачности нужен только порядок back-to-front, и каждый бит, потраченный на состояние, был бы отнят у глубины. Сама раскладка выглядит так:
stateThenDepth: [ bucket:8 | pipeline:6 | material:15 | depth:14 ] по возрастанию backToFront: [ bucket:8 | invDepth:35 ] дальние первыми
Сцена рендерится в формат r16g16b16a16Float; tone mapping (Khronos PBR Neutral), экспозиция и sRGB-кодирование отложены в композитный пасс, чтобы значения ярче белого дожили до пост-обработки (bloom с мягким порогом, цветокоррекция, виньетка, зерно). Всю пост-обработку зеркалирует и программный бэкенд - зачем, станет ясно в следующем разделе.
Зачем движку рендерер без GPU?
Программный растеризатор на Dart в 2026 году звучит как учебное упражнение, но в flutter3d он выполняет роль эталона. CPU-бэкенд рисует те же сцены, что и GPU-бэкенды (включая тени, PBR и пост-обработку), и тестовый набор сравнивает все три реализации попиксельно на общем наборе золотых сцен.
Ценность такой конструкции хорошо видна на примере. В какой-то момент WebGL разошёлся с Impeller на 19% пикселей на сцене с частицами, и ответить, кто из двух рисует правильно, глядя на картинки, было невозможно: обе выглядели правдоподобно. Рассудил программный растеризатор: его кадр совпал с Impeller, следовательно, ошибка в WebGL-бэкенде (ей оказался vertexAttribDivisor, «протёкший» из предыдущей сцены). Сегодня худшее расхождение между бэкендами по всему набору составляет 0,55% пикселей, а из примерно трёх тысяч тестов движка GPU требуется лишь тридцати: всё остальное, включая золотые кадры, выполняется headless в CI на каждый коммит, поскольку один из референсных рендереров не нуждается в видеокарте. Согласие двух независимо написанных реализаций - это свидетельство корректности; согласие реализации с самой собой - тавтология.

Что должен реализовать собственный бэкенд?
Вернусь к контракту HAL - той части, которая превращает «в теории можно написать свой бэкенд» в конкретную процедуру. Формальная часть контракта очевидна: интерфейсы GraphicsDevice, CommandEncoder и PassEncoder, дескрипторы пассов, хэндлы ресурсов, шестнадцать перечислений форматов. Отмечу одну деталь: PassEncoder намеренно отделён от CommandEncoder и не имеет метода submit, поэтому расширение рендера (например, система частиц), получившее PassEncoder, физически не может завершить чужой пасс - это ошибка компиляции, а не замечание на код-ревью.
Более интересна та часть контракта, которую сигнатуры показать не могут: бэкенд, ошибившийся в ней, компилируется и рисует неправильно, причём часто далеко от места ошибки. Несколько пунктов из задокументированного списка (каждый в своё время был оплачен отладкой):
очистка (clear) покрывает весь аттачмент независимо от viewport и scissor; GL такой семантики бесплатно не даёт (
clearBufferfvуважаетSCISSOR_TEST), и бэкенд обязан это компенсировать;прямоугольники всюду задаются от левого верхнего угла; бэкенд, у которого origin фреймбуфера находится внизу, переворачивает их самостоятельно;
GeometryUsage- не подсказка оптимизатору: WebGL навсегда привязывает буфер к его первому таргету, и буфер, загруженный как вершинный, уже никогда не станет индексным (попытка даётINVALID_OPERATION, вызов отрисовки молча выбрасывается, кадр приходит цветом очистки, а в логе пусто);пасс оставляет включёнными вершинные атрибуты, которые включил, и бэкенд на состоянии контекста (в отличие от бэкенда на командных буферах) должен выключать их сам. Это правило было найдено через рендер неба: у него восемь атрибутов, у обычного меша пять, у пост-стейджа ноль, и симптомом стало не отсутствие неба, а полностью чёрный кадр - среди молча выброшенных вызовов оказался и композитный, выводящий картинку на экран;
сэмплер, объявленный шейдером, обязан быть привязан: на одном из бэкендов непривязанный сэмплер приводит к нативному крашу без единого Dart-фрейма в стеке, поэтому движок в такой ситуации привязывает заглушку.
Проверяется всё это отдельным пакетом flutter3d_conformance. Набор двухъярусный: coreChecks покрывает очистки, загрузки и readback (всё, что можно спросить с бэкенда до первого скомпилированного шейдера), shaderChecks - остальное. Туда же складываются регрессионные проверки на уже известные проблемы: например, проверка «a pipeline switch leaves no stale bindings» без специального обхода упомянутого выше бага Flutter GPU со stale bindings падает на Metal.
Процедура в итоге выглядит так: вы реализуете интерфейсы, проходите conformance и получаете работающими три игры и три тысячи тестов движка на своей платформе.
Чем flutter3d отличается от flutter_scene?
Вопрос закономерный, и отвечать на него нужно аккуратно: flutter_scene активно развивается (0.23.0 на момент публикации), поддерживает тени, пост-обработку, веб через собственный WebGL2-бэкенд и даже gaussian splatting, а разрабатывает его один из авторов Impeller. Различие в замысле. flutter_scene - это scene graph и рендер, а физика и звук подключаются к нему нативными интеграциями (Rapier написан на Rust, FMOD и SoLoud на C++), то есть часть кода снова оказывается за FFI-границей. flutter3d держит весь стек в Dart (включая физику и аудио-микшер) и добавляет слои, которых у flutter_scene нет по замыслу: детерминированную симуляцию с фиксированным шагом, жанровые пакеты, редактор уровней. Наконец, бэкенд у flutter3d - внешняя точка расширения с задокументированным контрактом и conformance-тестами, а один из бэкендов работает вовсе без GPU, что и делает возможными регрессионные golden-тесты в CI. Практический вывод: для красивого рендера сцены внутри приложения путь через flutter_scene может оказаться короче (в некоторых аспектах он опережает flutter3d, но например сейчас еще не поддерживает светящиеся частицы, которые у меня поддержаны); для игры целиком, воспроизводимых тестов рендера или собственной платформы предназначен flutter3d.
Заодно отвечу на другие возможные вопросы. Почему не Flame - потому что Flame про 2D, а здесь задача другая. Почему не биндинг к Unity или Godot - потому что мне хотелось движок, который ведёт себя как обычная Dart-зависимость (pub add, hot reload, тесты), а не выносит всю интересную логику за языковую границу. Мешает ли сборщик мусора - GC мешает, когда ему дают работу, а горячий цикл кадра не аллоцирует (списки отрисовки переиспользуются, ключи сортировки лежат в типизированных массивах). Продакшн-реди ли это - нет: версия 0.4.0, API может меняться, ограничения перечислены в архитектурном документе отдельным разделом; при этом уже сейчас есть три работающие игры, ~3000 тестов и conformance-набор.
Как собрать на движке игру?
Испытание API движком было бы неполным без испытания движка игрой: скриншоты рендерят все, а полноценную игру держат немногие. Поэтому вместе с движком написаны три законченные игры разных жанров - dungeon-shooter, платформер и гонки, все три работают и в браузере на WebGL2-бэкенде (flutter3d.pleion.dev). Отдельно отмечу факт, которым я дорожу больше любого пункта фиче-листа: вторая и третья игры не потребовали изменить ни одной строки в пакетах движка. Жанровая механика (оружие и монстры шутера, двойной прыжок с coyote time в платформере, шинная модель с падением сцепления за пиком в гонках) живёт в жанровых пакетах поверх общего детерминированного слоя: фиксированный шаг симуляции, коллизии, навигация, позиционный звук, ввод с клавиатуры, геймпада и тача.

Путь до собственной игры проще всего показать на примере. Полный пошаговый разбор (по коммиту на каждый шаг) лежит в отдельном репозитории coin_climb, здесь приведу скелет. Зависимости:
dependencies: flutter3d: ^0.4.0 # рендерер, сцена, ассеты flutter3d_game: ^0.4.0 # фиксированный шаг, уровни, акторы flutter3d_game_platformer: ^0.4.0 # жанровый пакет платформера flutter3d_bridge: ^0.4.0 # мост из уровней и акторов в меши flutter3d_app: ^0.4.0 # ввод, настройки, выбор бэкенда flutter3d_audio: ^0.4.0
Обратите внимание: графического API в списке нет, его выбирает flutter3d_app по платформе. Уровень занимает 36 строк JSON; браши из него становятся одновременно и мешами, которые видно, и коллайдерами, по которым ходит персонаж (геометрия и физика собираются из одного источника и не могут разъехаться):
"brushes": [ { "at": [0.0, -0.5, 0.0], "size": [20.0, 1.0, 20.0], "material": "ground" } ], "entities": [ { "type": "player_spawn", "at": [0.0, 0.0, -7.0], "yaw": 0.0 }, { "type": "collectible", "at": [0.0, 0.8, -3.5], "what": "coin", "model": "assets/models/coin.glb" } ]
Ключевой момент, ради которого задумывались жанровые пакеты: уровень загружается с реестром жанра, после чего тип collectible обретает поведение:
final loaded = await LevelLoader().load(kLevel, device: device, registry: platformerRegistry(), rules: platformerRules(), ); final staged = stage(loaded.level, loaded.collision, input: _input, onFixture: fixtures.add); // каждый фиксированный шаг: staged.sim.step(dt);
Подбор монет, двойной прыжок, coyote time и анимация исчезновения монеты при подборе приезжают из пакета flutter3d_game_platformer вместе с реестром - в коде игры этой логики нет. HUD целиком состоит из одного виджета (Text('coins ${staged.runner.purse['coin']} / $kCoins')), а весь проект укладывается примерно в 600 строк собственного кода, из которых 36 занимает уровень.
Дополнительный бонус детерминированной симуляции: тест в репозитории игры проходит её headless в CI - зажимает «вперёд», прыгает и проверяет, что в кошельке персонажа прибавилась монета. Поскольку рендер на симуляцию не влияет в принципе, прогон воспроизводится одинаково на любой машине.

Так готов ли Flutter GPU к 3D-играм?
Мой ответ по итогам проекта: да, с оговорками, и все оговорки обходятся. Придётся жить без рантайм-шейдеров (а значит, с фиксированным набором материалов), писать карты теней в цветовые текстуры, самостоятельно планировать пассы по командным буферам и знать про stale bindings. Ни одна из этих особенностей не помешала собрать каскадные и кубические тени, HDR-конвейер с bloom и три играбельные игры, но каждая из них - аргумент в пользу того, чтобы не привязывать движок к одному API: когда завтра появится WebGPU-бэкенд у Flutter или изменится поведение Impeller, слой аппаратной абстракции позволит пережить это заменой одного пакета.
В этой статье я рассмотрел ограничения Flutter GPU и архитектуру, которая их изолирует, устройство сцены, освещения, теней и сборки кадра, роль программного растеризатора как эталона и контракт, по которому можно реализовать собственный бэкенд. За рамками остались физика, скелетная анимация, аудио-граф, формат уровней и редактор - если тема вызовет интерес, им можно посвятить отдельную часть.
Движок распространяется под лицензией MIT: пакеты опубликованы на pub.dev под pleion.dev, документация и туториалы собраны на flutter3d.pleion.dev, исходные тексты доступны на GitHub. Начать знакомство я рекомендую с трёх игр в браузере - они дают более честное представление и о движке, и о возможностях Flutter в 3D, чем любые скриншоты.
И отдельное приглашение для тех, кому интересно не только поиграть. Больше всего проекту сейчас нужны новые жанровые пакеты - заготовки механик, из которых игры собираются без правок движка, как платформер собрался из двойного прыжка и coyote time. Игровой слой под них готов: симуляция с фиксированным шагом, коллизии, навигация, позиционный звук и ввод уже написаны, а каркас нового проекта умеет сгенерировать редактор уровней. Порог входа пониже - новые уровни для существующих игр: формат - обычный JSON, к нему есть визуальный редактор. Ну и просто запуск игр на своих устройствах с баг-репортами тоже полезен, всегда нужны новые конфигурации GPU. Как устроен проект и с чего начать, описано в CONTRIBUTING.md в корне репозитория.
А своим студентам я теперь отвечаю коротко: да, можно, и вот ссылка.

