3D в игре на Flame: от одной модели до целого мира

—

от автора

River Sortie это шутер в духе River Raid, в который можно сыграть прямо в браузере. Самолёт летит вверх по бесконечной реке, сбивает танкеры, вертолёты и мосты, заправляется над складами. Всё, что в нём движется, это обычные компоненты Flame с хитбоксами Flame, и кто в кого попал, решает onCollisionStart. Рисует всё это в 3D flutter3d, а игровая логика об этом не знает. Ниже я расскажу, как два движка поделили работу, что для этого пришлось добавить в мост flame_flutter3d, и про второй, совсем короткий способ, когда в 2D-игре нужна одна трёхмерная модель.

River Sortie: самолёт летит вверх по реке и стреляет, подбитый танкер горит, внизу приборная панель Flame

River Sortie: самолёт летит вверх по реке и стреляет, подбитый танкер горит, внизу приборная панель Flame

Продолжение статьи «Свет в конце пайплайна: 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,));
Робот рисуется на холсте Flame: круги с priority 0 за ним, оранжевый квадрат с priority 2 проходит перед ним

Робот рисуется на холсте Flame: круги с priority 0 за ним, оранжевый квадрат с priority 2 проходит перед ним

Компонент сам загружает файл, ставит модель в свою маленькую сцену и наводит камеру так, чтобы модель поместилась целиком. Фон прозрачный, вокруг модели видна игра. Если в модели есть анимации, первая крутится по кругу, другую выбирает параметр 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), чтобы он обновлялся после всего, что добавит игра. Кадр выглядит так:

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

  2. Мостовые компоненты переносят положения между слоями.

  3. BridgeClock вызывает onTick, синхронизируются камеры, и виджет просит перерисовать 3D-слой.

  4. 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);

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

River Sortie: над сбитыми целями «+30» и «+60», вертолёт штопором уходит в воду, впереди склад топлива. Модель самолёта: Jet, Poly by Google, CC BY 3.0

River Sortie: над сбитыми целями «+30» и «+60», вертолёт штопором уходит в воду, впереди склад топлива. Модель самолёта: Jet, Poly by Google, CC BY 3.0

Река, которая не кончается

Река строится кусками впереди самолёта и освобождается позади. Этим занимается 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),    ]),  );}
Сбитый мост ломается посередине, каждая половина уходит в воду на своей опоре, «+500» рисует Flame над точкой сцены

Сбитый мост ломается посередине, каждая половина уходит в воду на своей опоре, «+500» рисует Flame над точкой сцены

Приборную панель, топливо, очки и сообщения уровней тоже рисует 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.

Meteor Yard, четвёртый уровень: голубой корабль игрока внизу, оранжевые боты-патрульные и пурпурные охотники. Модели кораблей: Space Kit, Kenney, CC0

Meteor Yard, четвёртый уровень: голубой корабль игрока внизу, оранжевые боты-патрульные и пурпурные охотники. Модели кораблей: Space Kit, Kenney, CC0

Тут два несовпадения, и замазывать их я не стал. Физика сообщает о паре коллайдеров, а 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/