River Sortie это шутер в духе River Raid, в который можно сыграть прямо в браузере. Самолёт летит вверх по бесконечной реке, сбивает танкеры, вертолёты и мосты, заправляется над складами. Всё, что в нём движется, это обычные компоненты Flame с хитбоксами Flame, и кто в кого попал, решает onCollisionStart. Рисует всё это в 3D flutter3d, а игровая логика об этом не знает. Ниже я расскажу, как два движка поделили работу, что для этого пришлось добавить в мост flame_flutter3d, и про второй, совсем короткий способ, когда в 2D-игре нужна одна трёхмерная модель.
Продолжение статьи «Свет в конце пайплайна: 3D-движок на чистом Dart (+gpu)».
Оригинальную River Raid написала Кэрол Шоу для Atari 2600, Activision выпустила её в 1982 году. Шоу была одной из первых женщин, которые профессионально делали игры, а её река строилась из псевдослучайной последовательности, поэтому трасса в каждой игре была одна и та же, хотя картридж её не хранил. В River Sortie так же: генератор реки работает от seed, и река одинаковая при каждом запуске.
Два способа соединить Flame и flutter3d
Пакет flame_flutter3d подключается двумя способами, и нужны они для разного.
Простой нужен 2D-игре, в которой должен появиться один трёхмерный предмет: персонаж на экране выбора, босс, который поворачивается к игроку, кубок на экране результатов. Model3dComponent рисует модель прямо на холсте Flame, и обычной FlameGame больше ничего не нужно.
Полный подкладывает под игру целый 3D-мир со своей камерой, светом и физикой. На нём построена River Sortie, и почти вся статья про него, потому что интересные решения были там. Но начну с простого, он короче.
Простой способ: одна модель в обычной игре
Model3dComponent это обычный PositionComponent. Даёте ему glTF-файл и размер, добавляете в FlameGame, которую показывает обычный GameWidget, и он рисует модель в свой прямоугольник на холсте Flame. HasFlutter3d и Flutter3dFlameWidget тут не нужны.
world.add(Model3dComponent( model: 'assets/models/robot.glb', size: Vector2.all(320), anchor: Anchor.center, priority: 1,));
Компонент сам загружает файл, ставит модель в свою маленькую сцену и наводит камеру так, чтобы модель поместилась целиком. Фон прозрачный, вокруг модели видна игра. Если в модели есть анимации, первая крутится по кругу, другую выбирает параметр animation:. Анимация идёт по часам Flame, так что на паузе игры она тоже стоит. С какой стороны смотрит камера, задаёт viewFrom:.
Модель рисуется на холсте Flame, а не на отдельном слое, поэтому порядок у неё такой же, как у спрайта. На гифке робот стоит между кругами с priority: 0 и квадратом с priority: 2, который проходит перед ним. Позиция, якорь, поворот, масштаб и zoom камеры работают как обычно.
Как кадр попадает на холст, зависит от бэкенда, и над этим пришлось подумать. На десктопе и телефоне flutter3d рисует через Impeller, и готовая текстура становится ui.Image без копирования. В браузере WebGL и WebGPU рисуют в canvas, который Flutter не композитит, поэтому там компонент читает пиксели обратно и декодирует их в картинку. Это работает, тот же пример я проверял в Chrome, но каждый кадр стоит копии пикселей компонента, и картинка отстаёт от игры на кадр. Для модели в несколько сотен пикселей я бы на это согласился. Если 3D нужно на весь экран, нужен полный способ.
GPU-устройство одно на всю игру, его закрывает последний удалённый компонент, а рендерер у каждой модели свой. Рендерер подгоняет свои буферы под размер того, что рисует, и две модели разного размера на одном рендерере пересоздавали бы их дважды за кадр.
Полный способ: 3D-мир под игрой
Два слоя в одном Stack
Flutter3dFlameWidget это Stack: снизу SceneSurface flutter3d, сверху GameWidget Flame. Каждый движок рисует свой слой своим рендером и в чужой не лезет. Flame лежит сверху, потому что ему нужен сырой ввод: в браузере 3D-поверхность это platform view, и при проверке попадания Flutter отдаёт указатель ему, так что всё, что хочет касаний и кликов, должно быть выше в дереве.
Игра River Sortie подмешивает HasFlutter3d и поэтому сама владеет своим 3D-миром: сцена, устройство, камера и рендерер это поля игры. Виджету тогда нужна только игра:
final class RiverGame extends FlameGame with HasFlutter3d, HasFixedStep, KeyboardEvents, HasCollisionDetection { // ...}// В дереве виджетов:Flutter3dFlameWidget(game: game)
Мир строится в onOpen3d. Этот метод вызывается один раз, когда игра уже загрузилась и 3D-устройство открыто: раньше загружать меши некуда.
Одни часы
Это решение я считаю главным в мосте. У Flame уже есть тикер, синхронизированный с vsync. Если завести второй для 3D, два таймера рано или поздно разойдутся, и я не знаю надёжного способа это исключить, кроме как не заводить второй. Поэтому часы в гибридной игре одни, и это часы Flame.
Виджет добавляет в игру компонент BridgeClock с очень большим приоритетом (1 << 20), чтобы он обновлялся после всего, что добавит игра. Кадр выглядит так:
-
Flame обновляет свои компоненты. Те, что шагают физику и акторов, работают здесь же, и колбэки столкновений приходят во Flame в этом же проходе.
-
Мостовые компоненты переносят положения между слоями.
-
BridgeClockвызываетonTick, синхронизируются камеры, и виджет просит перерисовать 3D-слой. -
SceneSurfaceрисует 3D-кадр.
River Sortie вдобавок подмешивает HasFixedStep: логика идёт в fixedUpdate фиксированными шагами, и секунда игры выходит одинаковой при любой частоте кадров. Вместе с генератором реки от seed это даёт одну и ту же реку и один и тот же полёт на мониторе в 60 Гц и в 120.
Одна плоскость
У Flame точка это Vector2, у flutter3d Vector3, и где-то надо решить, какая ось чем становится. Я вынес это в один класс, BridgePlane, и каждый мостовой компонент получает его в конструкторе. Когда каждый компонент придумывает соглашение сам, они рано или поздно расходятся.
BridgePlane.ground() это пол для игры с видом сверху: y Flame становится z сцены. BridgePlane.backdrop() это стена для сайд-скроллера: y остаётся y, постоянна глубина. В River Sortie вся игра лежит на одной плоскости, это вода:
static final BridgePlane river = BridgePlane.ground();
Позиция во Flame это x поперёк реки и y вдоль неё, вверх по реке y уменьшается. Всё, что на экране, наследует Object3dComponent: это PositionComponent Flame, у которого есть свой узел сцены. Так выглядит самолёт игрока:
final class JetComponent extends Object3dComponent with CollisionCallbacks, HasGameReference<RiverGame> { JetComponent({required super.node, required super.scene}) : super( plane: RiverGame.river, direction: SyncDirection.flameToScene, elevation: flightHeight, size: Vector2(1.5, 1.8), anchor: Anchor.center, ); @override Future<void> onLoad() async { await super.onLoad(); add(RectangleHitbox()); }}
direction выбирается один раз, при создании. Угадывать направление по тому, что изменилось последним, я не стал: две системы легко могут обе решить, что читает другая, и такая догадка ломается молча. elevation поднимает компонент над плоскостью: самолёты и вертолёты летят на высоте flightHeight, танкеры и склады стоят на воде.
Самолёт кренится в повороте, а Flame об этом не знает. У Object3dComponent есть visual, узел под узлом компонента, который мост не трогает. Игра крутит его как хочет:
void bankTowards(double stick, double dt) { bank += (stick * 0.55 - bank) * math.min(1.0, dt * 6.0); visual.setRotation(_upRiver * _roll(bank));}
Тем же способом подбитый вертолёт уходит в воду штопором, а танкер кренится и тонет: игра меняет visual и elevation, а позицию во Flame не трогает.
Столкновения решает Flame
Хитбоксы в River Sortie это хитбоксы Flame, и кто в кого попал, говорит обычный onCollisionStart:
@overridevoid onCollisionStart( Set<Vector2> intersectionPoints, PositionComponent other,) { super.onCollisionStart(intersectionPoints, other); final solid = switch (other) { TargetComponent(:final plan, :final down) => !down && plan.kind != TargetKind.depot, BridgeComponent(:final down) => !down, EnemyShotComponent() => true, _ => false, }; if (solid) game.crash(Crash.collision);}
Склад топлива не твёрдый: самолёт над ним заправляется, и игра узнаёт это по activeCollisions. Сбитая цель сразу теряет хитбокс, поэтому тонущий танкер это уже декорация, а не препятствие.
Берега хитбоксами не сделаны вовсе. Генератор реки для любой точки отвечает, вода там или суша, и самолёт спрашивает его каждый шаг:
final overWater = course.rowAt(distance).isWater(x, halfWidth: wingReach) && course.rowAt(distance + noseReach).isWater(x, halfWidth: 0.15);if (!overWater) crash(Crash.bank);
Плоский хитбокс на изогнутый берег пришлось бы пересобирать на каждом участке, а кривая и так знает, где вода.
Река, которая не кончается
Река строится кусками впереди самолёта и освобождается позади. Этим занимается ChunkStreamer: игра говорит ему, какие участки нужны, а он сам строит недостающие и отпускает лишние.
late final ChunkStreamer<_Stretch> _stretches = ChunkStreamer<_Stretch>( build: _buildStretch, drop: _dropStretch,);void _ensureStretches() => _stretches.cover( course.sectionIndexAt(distance - 25.0), course.sectionIndexAt(distance + 160.0),);
Участок это долина с берегами, вода, цели и мост в конце. Меш долины загружается на устройство при постройке и отдаётся рендереру при освобождении через releaseMeshAfterFrame: кадр, который ещё в полёте, может его рисовать, и отпускать раньше нельзя.
Камера
Камера следует за самолётом сзади и сверху:
chase = ChaseCamera( camera: camera3d, target: jet, offset: Vector3(0.0, 11.0, 11.0), lookOffset: Vector3(0.0, -flightHeight, -9.0), followAcross: 0.35, lookAcross: 0.5,);add(ChaseCameraComponent(chase));
С followAcross получилось не с первого раза. Камера, привязанная к x самолёта, поворачивала всю долину при каждом уклонении, а неподвижная теряла самолёт на узком экране. Треть пути поперёк оставляет в кадре оба берега и всё равно даёт почувствовать, как самолёт скользит. При крушении и взрыве склада камера трясётся: chase.rig.shake(0.5).
Эффекты: где Flame, а где 3D
Здесь Flame и 3D перемешаны теснее всего, и эта часть игры мне нравится больше других.
Выстрелы самолёта это InstancedObject3dComponent: каждый выстрел обычный компонент Flame со своим хитбоксом, а рисуются все одним вызовом, инстансами одного меша. При пяти выстрелах в секунду и секунде жизни их в воздухе всегда несколько, и рисовать каждый отдельно было бы жалко.
Огонь, искры, брызги и дым это 3D-частицы, Particles3dComponent. Их два: один пул светится и складывает цвет, второй затемняет то, что за ним. Шагают они по часам Flame, так что на паузе огонь замирает.
Вспышка взрыва это анимация спрайтов Flame, поставленная в сцену карточкой, которая всегда смотрит в камеру, SpriteBillboardComponent. Камыш на берегах сделан так же.
Очки над сбитой целью рисует сам Flame, в своём viewport, обычным TextComponent с MoveByEffect. Точку на экране для него даёт проектор моста:
void _popScore(int points, Vector3 at) { final screen = projector.toScreen(at); if (screen == null) return; camera.viewport.add( TextComponent( text: '+$points', textRenderer: _popPaint, position: screen, anchor: Anchor.center, )..addAll(<Component>[ MoveByEffect(Vector2(0.0, -48.0), EffectController(duration: 0.8)), RemoveEffect(delay: 0.8), ]), );}
Приборную панель, топливо, очки и сообщения уровней тоже рисует Flame, на своём холсте поверх сцены.
Звук оттуда, где взорвалось
Звук идёт через пакет flame_flutter3d_audio. AudioSceneComponent держит банк звуков, SoundEmitterComponent отвечает за петли: гул двигателя, который поднимается с газом, писк заправки и сигнал низкого топлива. Взрыв слышно с того места, где он случился. Звуки в духе картриджа 1982 года, квадратные волны и шум сдвигового регистра, их пишет скрипт tool/make_sounds.py, ничего не взято из чужих игр.
Динамики открываются при первом взлёте, а не при запуске. Взлёт это первое нажатие игрока, а браузер разрешает странице звук только после него, так что игра, открывшая аудио при старте, первый звук бы не услышала.
Один ввод
FlameInputBridge принимает клавиши, которые получает Flame, и пишет их в Bindings и InputState из flutter3d_game, в те же объекты, которые читает нативная игра на flutter3d. Своей таблицы клавиш у моста нет: если игрок переназначил кнопку в меню, это должно работать и в сборке с Flame, а две разошедшиеся таблицы выглядят как переназначение, которое молча не сработало.
На телефоне это окупается. Экранный стик это обычный JoystickComponent Flame, followJoystick пишет его отклонение туда же, куда пишет стик геймпада, а bindButton превращает HudButtonComponent в кнопку огня:
inputBridge.bindButton(trigger, fire);camera.viewport.addAll(<Component>[stick, trigger]);add(inputBridge.followJoystick(stick));
Код, который ведёт самолёт, не знает, откуда пришла ось.
Чего River Sortie потребовала от моста
Игра оказалась самым требовательным пользователем моста, и большая часть того, что описано выше, появилась потому, что она этого просила: фиксированный шаг, частицы на часах Flame, спрайты Flame в сцене, проектор для текста над точкой сцены, звук из точки сцены, поток участков мира. Пока я делал только примеры, ничего из этого не было нужно.
Обратный случай: физика в 3D, события во Flame
В River Sortie всё решает Flame, а 3D-слой только рисует. Бывает наоборот, и это тоже работает: в аркаде Meteor Yard столкновения считает трёхмерная физика flutter3d, а CollisionBridge пересылает их в CollisionCallbacks Flame. Таран, посчитанный в 3D, приходит во Flame обычным onCollisionStart.
Тут два несовпадения, и замазывать их я не стал. Физика сообщает о паре коллайдеров, а Flame ждёт PositionComponent, поэтому какой компонент стоит за коллайдером, говорит сама игра через resolveOther. Если там стена уровня и компонента нет, мост не вызывает ничего: придумать компонент значило бы сказать Flame-коду, что он столкнулся с тем, чего для него не существует. И в колбэке Flame есть место только для точек пересечения, а не для нормали и глубины, так что мост отдаёт одну приблизительную точку, а настоящие нормаль и глубина остаются на стороне flutter3d.
И одна ловушка, которую я нашёл на собственной ошибке. Два твёрдых тела, которые касаются, никогда не перекрываются: решатель останавливает каждое у поверхности другого. А мир столкновений сообщает о перекрытиях. Поэтому охотник висел вплотную к кораблю без единого удара. Лечится триггером чуть больше корпуса: он не твёрдый, на нём никто не останавливается, и бот рядом с бортом его перекрывает.
Быстрый старт
Зависимости:
dependencies: flame: ^1.38.2 flame_flutter3d: ^0.8.5 flutter3d: ^0.8.3 flutter3d_game: ^0.8.0 # Bindings, таблица клавиш flutter3d_sim: ^0.8.1 # InputState и GameAction
Ещё нужно включить Flutter GPU, иначе вместо 3D будет пустой экран. Он включается в каждом приложении отдельно. В macos/Runner/Info.plist и ios/Runner/Info.plist:
<key>FLTEnableFlutterGPU</key><true/><key>FLTEnableImpeller</key><true/>
На Android внутри <application> в AndroidManifest.xml:
<meta-data android:name="io.flutter.embedding.android.EnableFlutterGPU" android:value="true" />
Приложение без этих ключей запускается, открывает окно и не рисует ни одного 3D-кадра, потому что не может загрузить библиотеку шейдеров. Для простого способа на десктопе и телефоне это тоже нужно.
Минимальный пример полного способа лежит в packages/flame_flutter3d/example, простого в example/lib/model3d_main.dart там же.
Неочевидное
Чёрный прямоугольник поверх 3D. GameWidget рисует backgroundColor() игры непрозрачным прямоугольником, по умолчанию чёрным. В обычной игре это незаметно, а в нашем Stack он лежит поверх 3D-слоя: сцена честно рендерится, а на экране чернота. Игра с HasFlutter3d отдаёт прозрачный фон сама, без миксина наследуйтесь от TransparentFlameGame.
Часы, которые шли первыми. Flame при равных приоритетах обновляет компоненты в порядке добавления. Виджет добавляет BridgeClock из своего первого build, и если устройство он открывает сам, это случается раньше, чем появятся компоненты игры. Часы оказывались первыми, а не последними. Поэтому у них явный приоритет 1 << 20.
Перерисовка без отставания. Если попросить 3D-слой перерисоваться прямо из BridgeClock.update, в момент, когда Flutter собирает дерево, setState падает с «called during build». Мост откладывает перерисовку до конца кадра. Я долго считал, что это стоит кадра отставания, и даже написал так в документации. Это неправда: отложенный вызов только помечает 3D-слой, а следующий кадр начинается с тикеров, и тикер Flame обновляет игру раньше, чем Flutter дойдёт до сцены. Оба слоя рисуют одно и то же состояние.
Знак у кватерниона. Quaternion.axisAngle(axis, θ) из vector_math поворачивает узел на +θ, а Quaternion.rotated(v) поворачивает вектор на −θ. Мост когда-то подбирал знак через rotated, и тесты, гонявшие угол туда и обратно, проходили: обе функции ошибались одинаково. А на плоскости пола поворот Flame рисовался зеркально. Теперь обе считают через матрицу, которой узел действительно рисуется, и тест проверяет направление на экране, а не только обратный ход.
Чего пока нет
В полном способе Flame всегда сверху. Слои не перемешиваются по глубине: HUD, карта и спрайты над сценой работают, а 3D-колонна, за которую уходит 2D-спрайт, нет. Одну модель между 2D-слоями можно поставить простым способом, в браузере ценой копии кадра.
Нормали и глубины контакта во Flame не приходят. Колбэк Flame для них не приспособлен, и когда они нужны, их читают на стороне flutter3d.
Попробовать
River Sortie играется в браузере: стрелки или WASD, пробел стреляет, на телефоне стик и кнопка. Meteor Yard там же. В showcase у каждого механизма моста своя страница с живой сценой и пошаговым разбором.
Исходники игр и пакета на GitHub, пакет flame_flutter3d на pub.dev. Если поставите его под свою игру на Flame, расскажите, как прошло и особенно где сломалось: issues на GitHub или r/flutter3d.
ссылка на оригинал статьи https://habr.com/ru/articles/1089382/