Добавляем 3D в игру на Flame, не переписывая её

«У меня игра на Flame, можно ли добавить в неё 3D?» Можно, и игру не придётся переписывать: корабль летает по трёхмерному двору со светом и тенями, а джойстик, HUD и вся игровая логика остаются на Flame. У двух движков одни часы, одна физика и один ввод, поэтому таран, который посчитала 3D-физика, приходит во Flame обычным onCollisionStart. Я разберу по шагам небольшую аркаду на flame_flutter3d, покажу рецепты, которые можно брать в свою игру, и неочевидные места, на которые наступил сам. В аркаду можно сыграть прямо в браузере.
Продолжение статьи «Свет в конце пайплайна: 3D-движок на чистом Dart (+gpu)».
Этот вопрос после прошлой статьи задавали чаще всего. Начиная с flutter3d 0.7 для ответа на него есть пакет flame_flutter3d, мост между двумя движками.

Второй движок в одном окне сам по себе ничего не даёт. Интересно становится, когда у двух миров одно время, одни столкновения и один ввод. Тогда можно делать гибрид в любую сторону: 3D-сцену с картой и интерфейсом на Flame, 2D-платформер с объёмным задним планом, игру с видом сверху, где логика и спрайты живут во Flame, а физика, свет и тени считаются в 3D. На картинке ниже как раз пример такого поведения. Ящик падает в трёхмерной сцене, в углу та же сцена нарисована плоской картой Flame, и когда ящик касается площадки, об этом узнаёт обычный Flame-компонент и красит площадку в зелёный сразу на обоих слоях.

Быстрый старт
Самый короткий путь к гибридной игре это пример из пакета, flame_flutter3d/example. В нём на одном экране HUD Flame поверх 3D, кубик, которым управляет Flame по стрелкам, и ящик, о посадке которого Flame узнаёт через мост столкновений.

Зависимости:
dependencies:
flame: ^1.38.2
flame_flutter3d: ^0.8.1
flutter3d: ^0.8.1
flutter3d_game: ^0.8.0 # Bindings, таблица клавиш
flutter3d_sim: ^0.8.0 # InputState, акторы и физика
Ещё нужно включить Flutter GPU, иначе вместо 3D будет пустой экран. Он включается в каждом приложении отдельно, в настройках платформы. После flutter create --platforms=macos . в macos/Runner/Info.plist добавьте:
<key>FLTEnableFlutterGPU</key>
<true/>
<key>FLTEnableImpeller</key>
<true/>
То же для iOS в ios/Runner/Info.plist, а на Android это io.flutter.embedding.android.EnableFlutterGPU в AndroidManifest.xml. Приложение без этих ключей запускается, открывает окно и не рисует ни одного 3D-кадра, потому что не может загрузить библиотеку шейдеров.
Как это устроено
Два слоя в одном Stack
Всё начинается с одного виджета:
final camera = CameraNode(name: 'eye');
Flutter3dFlameWidget(
game: MyFlameGame(),
camera: camera,
buildScene: (device) => Scene()..add(camera),
)
Внутри это Stack: снизу SceneSurface flutter3d, сверху GameWidget Flame. Каждый движок рисует свой слой своим рендером и в чужой не лезет. Flame лежит сверху не случайно: в браузере 3D-поверхность это platform view (HtmlElementView), и при проверке попадания Flutter он принимает указатель на себя, поэтому всё, что хочет сырой ввод, должно быть выше него в дереве.

Один цикл событий
Главное решение в мосте касается часов. У Flame уже есть тикер, синхронизированный с vsync. Если завести второй для flutter3d, два таймера рано или поздно разойдутся. Я не знаю надёжного способа это исключить, кроме как не заводить второй, поэтому часы в гибридной игре одни, и это часы Flame.
Виджет добавляет в игру обычный компонент BridgeClock. Каждый кадр, после того как обновились все остальные компоненты, он вызывает onTick(dt) с тем же dt, который получил Flame, и оттуда шагает всё, что есть на стороне flutter3d. Кадр гибридной игры выглядит так:
Flame обновляет свои компоненты. Среди них
ActorSystemComponent, который шагает акторов, и тот, кто шагает физику и мир столкновений. Столкновения, найденные на этом шаге, тут же приходят во Flame-колбэки.Мостовые компоненты переносят положения между слоями.
BridgeClockвызываетonTick, там синхронизируется камера, и виджет просит Flutter перерисовать 3D-слой.SceneSurfaceрисует кадр.

Из этого следует второе правило: общую симуляцию шагает ровно один компонент. RigidBodyComponent никогда не зовёт Dynamics.step сам, иначе сто мостовых ящиков шагнули бы общий мир сто раз за кадр. С акторами так же: ActorSystem требует пары beginStep и step один раз за кадр, и зовёт их только ActorSystemComponent.
Одна плоскость
У Flame точка это Vector2, у flutter3d Vector3, и где-то надо договориться, какая ось чем становится. Я вынес это в один класс, BridgePlane, и каждый мостовой компонент получает его в конструкторе. Если каждый компонент изобретает соглашение сам, они рано или поздно разойдутся.
Плоскостей две. BridgePlane.ground это пол для игры с видом сверху: y Flame становится z flutter3d, высота постоянна. BridgePlane.backdrop это стена для сайд-скроллера: y Flame остаётся y, постоянна глубина. У Flame y растёт вниз по экрану, у flutter3d вверх, поэтому стена по умолчанию его переворачивает.
Кто кого пишет, выбирается один раз, при создании компонента: SyncDirection.sceneToFlame или flameToScene. Угадывать направление по тому, что изменилось последним, я не стал: две системы легко могут обе решить, что читает другая, и тогда такая эвристика ломается молча. Физическое тело и актор по умолчанию пишут со стороны сцены, потому что их положение решает решатель, а Flame только читает.

Столкновения
Столкновения считает трёхмерная физика, а узнать о них хочется во Flame, привычным способом. CollisionBridge вешается слушателем на коллайдер flutter3d и пересылает его события в CollisionCallbacks Flame-компонента. Тут два несовпадения, и оба я решил не замазывать.
Первое: flutter3d сообщает о паре коллайдеров, а Flame ждёт PositionComponent. Физика про Flame ничего не знает, коллайдер не носит с собой компонент, поэтому найти второй компонент должен сам вызывающий через resolveOther. Если тот вернул null, потому что с той стороны стена уровня или тело без Flame-компонента, мост не вызывает ничего. Придумать компонент или отдать null значило бы сказать Flame-коду неправду: что он столкнулся с тем, чего с его точки зрения не существует.
Второе: в колбэке Flame есть только набор точек пересечения, места для нормали и глубины там нет. Мост отдаёт одну точку, середину между центрами двух коллайдеров, спроецированную на плоскость компонента. Для двух одинаковых коробок это центр перекрытия, а у коробок разного размера точка может оказаться и снаружи, так что считайте её приблизительной. Настоящие нормаль и глубина остаются доступны на стороне flutter3d.
Ввод и камера
FlameInputBridge принимает клавиатуру и перетаскивание из Flame и пишет их в те же Bindings и InputState, что и нативный ввод flutter3d. Своей таблицы клавиш у моста нет. Если игрок переназначил прыжок в меню, переназначение должно работать и в сборке с Flame, а две разошедшиеся таблицы со стороны неотличимы от переназначения, которое молча не сработало. Геймпад опрашивает PadInput и пишет туда же.

На телефоне клавиш нет, и тут общий ввод особенно выручает. Виртуальный джойстик это обычный JoystickComponent из Flame: слой Flame сверху, касания достаются ему. Каждый кадр отклонение стика уходит в InputState тем же вызовом, каким его передаёт стик геймпада:
final stick = joystick;
if (stick != null) {
// У Flame вниз по экрану плюс, у оси движения вперёд плюс.
inputState.setStickAxis(stick.relativeDelta.x, -stick.relativeDelta.y);
}
Дальше moveAxis складывает стик с нажатыми клавишами, и всё, что читает эту ось, не знает, откуда она пришла. В Meteor Yard джойстик включается на Android и iOS (в репозитории пока есть только Android-сборка), а код, который ведёт корабль, ради него не менялся.

CameraSyncController держит камеру flutter3d и Viewfinder Flame на одном кадрировании. Положение идёт через ту же BridgePlane. Масштаб у Flame это одно число, пиксели на единицу мира, и честная пара ему есть только у ортографической камеры, её высота. Мост считает zoom = 1 / height: это соглашение, обратимое, но не точное до пикселя, для точности нужен размер вьюпорта. У перспективной камеры такого числа нет, и с ней мост синхронизирует только положение.

Игра по шагам
Разберу Meteor Yard, пятую демо-игру движка. Это вид сверху: корабль летает по двору с колоннами и камнями, боты ходят по дорожкам, и их надо сбить тараном. Таран засчитывается, только если корабль летит на бота. Если бот врезался в корабль сам, сбоку или сзади, это удар по кораблю. Уровней четыре, и на поздних боты охотятся за кораблём и уходят от тарана. В игре работают все пять мостов сразу. Прежде чем читать код, в неё можно сыграть прямо в браузере: WASD или стрелки, а на телефоне экранный джойстик.
Шаг 1. Игра и виджет. Игра наследуется от TransparentFlameGame, о причине в разделе «Неочевидное». Создаётся она один раз, в состоянии виджета, а не в build:
class _ArcadeScreenState extends State<ArcadeScreen> {
final ArcadeGame _game = ArcadeGame();
@override
// В реальном коде виджет лежит в Stack рядом с Flutter-HUD.
Widget build(BuildContext context) => Flutter3dFlameWidget(
game: _game,
camera: _camera,
buildScene: (GraphicsDevice device) {
final scene = Scene();
_game.spawnWorld(device, scene);
unawaited(_game.dressWithCrafts()); // модели кораблей, шаг 9
return scene;
},
onTick: _onTick,
);
}
Шаг 2. Сцена. Двор собран из примитивов движка: пол из PlaneShape, колонны из CuboidShape, камни из SphereShape, и солнце. Корабли это модели из Space Kit от Kenney под CC0, о них в шаге 9.
scene
..add(_groundMesh(device))
..add(_prop(device, CuboidShape(size: Vector3(0.9, 3.2, 0.9)),
Vector3(-5.0, 1.6, -2.0), Vector4(0.55, 0.55, 0.6, 1.0), 'pillar 1'))
..add(_prop(device, SphereShape(radius: 0.7),
Vector3(3.5, 0.7, -5.5), Vector4(0.4, 0.32, 0.26, 1.0), 'rock 1'))
..add(LightNode(name: 'sun', intensity: 3.2)
..setLocalForward(Vector3(-0.35, -1.0, -0.2)));
Стены двора вообще без меша, это четыре статических коробки в мире столкновений. Их не видно, но сквозь них не пролетит ни корабль, ни бот.
Шаг 3. Кто шагает мир. Физика и акторы шагают из двух обычных компонентов Flame, и приоритеты у них отрицательные, чтобы они отработали раньше тех, кто читает результат:
final actorStepper = ActorSystemComponent(
system: actorSystem,
focus: () => _shipBody.position,
)..priority = -120;
final physicsStepper = _PhysicsStepComponent(
dynamics: dynamics,
world: collisionWorld,
afterStep: () => shipSensor
..position.setFrom(_shipBody.position)
..refreshBounds(),
)..priority = -110;
ActorSystemComponent есть в пакете, а готового компонента для шага физики в flame_flutter3d пока нет, поэтому игра пишет его сама. Он короткий:
final class _PhysicsStepComponent extends Component {
_PhysicsStepComponent({
required this.dynamics,
required this.world,
required this.afterStep,
});
final Dynamics dynamics;
final CollisionWorld world;
final void Function() afterStep; // то, что следует за телами после шага
@override
void update(double dt) {
super.update(dt);
dynamics.step(dt);
afterStep();
world.update();
}
}
Он делает dynamics.step(dt), переставляет сенсор корабля туда, куда решатель передвинул корпус, и зовёт world.update(). Последний вызов рассылает события столкновений, и с этого места они уже во Flame. Вызывать world.update() после шага, а не до, важно: иначе колбэки получат перекрытия прошлого кадра.
Шаг 4. Корабль. Это твёрдое тело в общем мире столкновений, узел сцены и компонент, который связывает одно с другим на плоскости пола. Мост столкновений слушает не корпус, а сенсор, триггер чуть больше корпуса, и ищет второго участника в словаре «коллайдер → компонент», который игра ведёт сама:
ship = ShipComponent(
body: _shipBody,
node: holder,
scene: scene,
plane: ArcadeGame.groundPlane,
);
_colliderComponents[_shipBody.collider] = ship;
shipSensor = collisionWorld.add(
Collider(
shape: CollisionBox(Vector3(0.6, 0.5, 0.6)),
position: ArcadeGame.shipStart.clone(),
kind: ColliderKind.trigger,
),
);
CollisionBridge(
collider: shipSensor,
component: ship,
resolveOther: (Collider other) => _colliderComponents[other],
);
ship.onCollisionStartCallback = (points, other) {
if (other is ActorComponent) _onShipHitBot(other);
};
Стены в словарь не попадают, поэтому удар о стену Flame не видит, а удар о бота видит. Почему слушать надо сенсор, а не корпус, я выяснил на собственной ошибке, она в разделе «Неочевидное».
Шаг 5. Боты. Каждый бот это актор flutter3d_sim, CharacterController вместо твёрдого тела и ActorComponent на той же плоскости. Гравитация у них нулевая, и летают они выше, чем достаёт проба земли контроллера, так что бот просто всегда в воздухе. Почему не твёрдое тело, рассказано в разделе «Неочевидное».
Решает, куда лететь, мозг BotBrain, и ActorSystem зовёт его каждый шаг. Позиция корабля для него это фокус системы, а направление к нему приходит готовым в toFocus. Поведений три, срабатывает первое подходящее:
// Сокращённо: полностью в lib/src/bot_brain.dart.
if (dodgeRadius > 0 && heading != null && distance < dodgeRadius) {
// Корабль летит прямо на меня: шаг в сторону с его курса.
if (along.dot(fromShip) > 0.6) { it.steer(side); return; }
}
if (chaseRadius > 0 && distance < chaseRadius) {
it.steer(toShip / distance); // погоня
return;
}
it.steerTowards(_towardTo ? to : from); // патруль

Шаг 6. Ввод. Игра получает клавиши как любая игра на Flame и отдаёт их мосту, а в update читает ось движения из общего InputState:
final axis = inputState.moveAxis;
if (axis.x != 0.0 || axis.y != 0.0) _shipBody.wake();
// Вперёд это вверх по экрану, а у камеры сверху верх смотрит в -Z.
shipHeading.setValues(axis.x * shipSpeed, 0.0, -axis.y * shipSpeed);
_shipBody.velocity.setFrom(shipHeading);
shipHeading пригодится дважды: по нему судят таран, и за ним следят уклоняющиеся боты.
Шаг 7. Камера. Камера flutter3d ортографическая и смотрит строго вниз, причём её «верх» развёрнут так, чтобы +Z сцены совпадал с +Y Flame на экране. Тогда ни одному слою не нужно переворачивать ось ради другого. В onTick видоискатель Flame следует за кораблём, но не выходит за границы двора, а контроллер переносит это на 3D-камеру:
late final CameraSyncController _cameraSync = CameraSyncController(
camera: _camera,
viewfinder: _game.camera.viewfinder..zoom = 1.0 / viewHeight,
plane: ArcadeGame.cameraPlane,
direction: SyncDirection.flameToScene,
);
void _onTick(double dt) {
final window = _game.size;
// На первом кадре размер окна ещё нулевой.
final aspect = window.y > 0.0 ? window.x / window.y : 16.0 / 9.0;
double held(double at, double halfView, double halfYard) =>
halfView >= halfYard
? 0.0
: at.clamp(halfView - halfYard, halfYard - halfView);
_game.camera.viewfinder.position = Vector2(
held(_game.ship.position.x, viewHeight * 0.5 * aspect, arenaHalfWidth),
held(_game.ship.position.y, viewHeight * 0.5, arenaHalfDepth),
);
_cameraSync.advance(dt);
}
zoom нужно задать до первой синхронизации. Контроллер пишет в высоту ортографической камеры 1 / zoom, а у Flame по умолчанию zoom равен единице, и кадр высотой 22 метра сжался бы до одного метра. Ограничение по границам двора нужно, чтобы у края двора половину окна не занимала пустота под полом.
Шаг 8. Таран или удар. Колбэк Flame получает бота и решает, кто в кого врезался:
void _onShipHitBot(ActorComponent bot) {
if (gameOver || !bots.contains(bot)) return; // уже сбит в этом кадре
final body = bot.actor.body;
if (body == null) return;
if (isRam(shipHeading, body.position - shipSensor.position)) {
bots.remove(bot);
rammed++;
_pendingRemovals.add(bot);
return;
}
if (ship.isFlashing) return;
hits++;
ship.flash();
}
isRam проверяет, что корабль летит не медленнее метра в секунду и бот лежит не дальше 60° от его курса. После удара корабль полсекунды мигает и в это время неуязвим, иначе один бот, скользнувший вдоль борта, снял бы несколько ударов подряд.
Сбитый бот исчезает, но удалить его прямо в колбэке нельзя. Колбэк зовётся из CollisionWorld.update, а тот из компонента физики внутри обхода детей Flame. Удалить актора там значит поменять список коллайдеров, пока мир столкновений по нему идёт, а убрать компонент значит поменять детей, пока их обходит Flame. Поэтому колбэк только кладёт бота в очередь, а игра разбирает её в начале следующего кадра, до того как Flame пойдёт по детям:
@override
void update(double dt) {
_drainHits();
// ...
super.update(dt);
}
Шаг 9. Уровни и модели. Уровни это таблица:
const List<ArcadeLevel> arcadeLevels = <ArcadeLevel>[
ArcadeLevel(bots: 3, botSpeed: 2.6, maxHits: 3),
ArcadeLevel(bots: 5, botSpeed: 3.2, maxHits: 3,
hunters: 1, chaseRadius: 5.0),
ArcadeLevel(bots: 6, botSpeed: 3.6, maxHits: 2,
hunters: 2, chaseRadius: 6.0, dodgeRadius: 3.0),
ArcadeLevel(bots: 8, botSpeed: 4.2, maxHits: 2,
hunters: 4, chaseRadius: 7.0, dodgeRadius: 3.5),
];
Ботов больше, они быстрее, со второго уровня часть охотится, с третьего все уходят от тарана, а права на ошибку меньше. Любой бот медленнее корабля, иначе охотника нельзя было бы протаранить. Через две секунды после очистки уровня начинается следующий, после проигрыша уровень повторяют Enter или пробел, а на телефоне касание строки статуса. Боты стартуют каждый со своей точки дорожки, со сдвигом по золотому сечению: когда все начинали с краёв, двор маршировал двумя колоннами.
Корабли сначала рисуются примитивами. Модели грузятся асинхронно и подменяют примитив внутри узла-держателя, а мосты всё это время ведут держатель и о подмене не знают. Kenney рисует все корабли одной оранжевой палитрой, поэтому акцентный материал перекрашивается при загрузке: у игрока голубой, у патрульных оранжевый, у охотников пурпурный. Между держателем и моделью стоит узел, который поворачивает корабль носом по курсу. Держатель мы не поворачиваем: его ведёт мост, и поворот держателя он каждый кадр переносит в angle Flame-компонента. Узел под держателем мосты не видят, его можно крутить как угодно.
Что даёт 3D-слой игре
Про постобработку будет отдельная статья, здесь только то, из чего собирается игра.

Меши из кода. Все примитивы это значения, а не статические методы. Базовый генератор это поверхность вращения LatheShape, из неё выведены сфера, цилиндр, конус, тор, капсула и диск, плюс любой свой профиль. Рядом куб и плоскость, а для сетки вершин вручную есть MeshBuilder. Если нужно редактировать меш как в моделлере, в flutter3d_mesh есть выдавливание граней и рёбер, разрез петлёй, отмена правок и упрощение меша, а LodGroup в ядре переключает уровни детализации по размеру на экране.
Свет и тени. Направленный свет, точечные и прожекторы с физическим затуханием, как в glTF, и прямоугольные светящиеся панели. Солнце даёт каскадные тени, три каскада по умолчанию, с фильтром на выбор: PCF, мягкие PCSS или EVSM. Точечные источники и прожекторы отбрасывают тени через кубический атлас, до шести источников с тенью одновременно. Если источников много, включите clusteredLights: вид режется на ячейки, и каждый фрагмент берёт источники своей ячейки, а не ограниченный набор на весь вызов отрисовки. Пол под 64 лампами в этом режиме освещён всеми. Для мелких предметов, у которых тень из карты теряется, есть контактные тени.
Модели. glTF и GLB грузятся в фоновом изоляте, материалы можно менять на ходу, как с перекраской кораблей выше.
Физика и столкновения. Твёрдые тела, контроллер персонажа с шагом по ступенькам и пробой земли, триггеры, статическая геометрия уровня. Всё это в одном мире столкновений, и мост видит его целиком.
Персонажи. Скиннинг из glTF, анимации с переходами, инверсная кинематика (двухзвенная и FABRIK), на которой можно поставить ногу на ступеньку, и root motion, когда анимация двигает самого персонажа и ноги не скользят по полу.
Детерминизм. Шаг симуляции не читает часы, случайность в нём только из зерна, а тригонометрия в нём своя, на Dart. Прогон повторяется бит в бит, его можно записать в файл и проиграть заново. На этом же стоит сетевая игра с откатом.
Шпаргалка
Короткие рецепты для своей игры. Код взят из примера в пакете и из Meteor Yard.
Показать объект Flame в 3D. Компонент хранит положение во Flame, а мост каждый кадр переносит его в узел сцены. Двигаете position, как у любого PositionComponent, и 3D-модель едет следом:
final cube = Object3dComponent(
node: cubeMesh, // любой SceneNode: меш, модель, группа
scene: scene,
plane: BridgePlane.ground(height: 0.5),
direction: SyncDirection.flameToScene,
position: Vector2(-2.0, 0.0),
);
add(cube);
Узнать во Flame о событии 3D-физики. Тело двигает физика, компонент читает его и получает обычные колбэки Flame. Если нужен сигнал «что-то попало в зону», ставьте триггер:
final pad = collisionWorld.add(Collider(
shape: CollisionBox(Vector3(1.0, 0.05, 1.0)),
position: Vector3(2.0, 0.05, 0.0),
kind: ColliderKind.trigger,
));
CollisionBridge(
collider: crate.collider,
component: crateComponent, // RigidBodyComponent с onCollisionStart
resolveOther: (other) => other == pad ? padComponent : null, // ваш компонент площадки
);
Всё, для чего resolveOther вернул null, мост молча пропускает. Так стены уровня не засыпают Flame событиями.
Поймать касание двух твёрдых тел. Твёрдые тела останавливаются у поверхности друг друга и не перекрываются, а мир столкновений сообщает только о перекрытиях. Слушайте триггер чуть больше корпуса и переставляйте его за телом после каждого шага физики:
dynamics.step(dt);
sensor..position.setFrom(body.position)..refreshBounds();
collisionWorld.update();
Удалить объект из колбэка столкновения. Прямо в колбэке нельзя: мир столкновений и Flame в этот момент обходят свои списки. Кладите в очередь и разбирайте в начале следующего кадра:
@override
void update(double dt) {
for (final bot in _pendingRemovals) {
actorSystem.remove(bot.actor);
bot.removeFromParent();
}
_pendingRemovals.clear();
super.update(dt);
}
Обновлять Flutter-HUD каждый кадр. Flutter3dFlameWidget перестраивает только себя. Виджет рядом с ним в Stack обновляйте сами из onTick. Откладывайте это до конца кадра: onTick иногда приходит изнутри build, и тогда прямой setState падает. Отставания это не даёт, почему, рассказано в разделе «Неочевидное».
void _onTick(double dt) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) setState(() {});
});
}
Или рисуйте HUD прямо во Flame, TextComponent в игре, как в примере из пакета. Тогда перестраивать ничего не нужно.
Если вместо 3D чернота, пройдитесь по списку:
включены ли
FLTEnableFlutterGPUиFLTEnableImpeller(на AndroidEnableFlutterGPU);игра наследует
TransparentFlameGame, а неFlameGame;камера не стоит внутри объекта: голая
CameraNodeнаходится в начале координат;игра создаётся один раз в
State, а не вbuild;у ортографической камеры, которую ведёт Flame, задан
zoomвидоискателя: приzoom = 1кадр будет высотой в метр.
Неочевидное
Чёрный прямоугольник поверх 3D. GameWidget рисует backgroundColor() игры непрозрачным прямоугольником под своим холстом, по умолчанию чёрным. В обычной игре на Flame это незаметно, а в нашем Stack этот прямоугольник лежит поверх 3D-слоя: сцена честно рендерится, с правильным светом и размером, а на экране чернота. Поэтому игра для моста наследует TransparentFlameGame с прозрачным фоном.
Часы, которые шли первыми. Flame при равных приоритетах обновляет компоненты в порядке добавления. Виджет добавляет BridgeClock из своего первого build, и если устройство он открывает сам, это случается раньше, чем появятся компоненты игры. Часы оказывались первыми, а не последними. Поэтому у BridgeClock явный приоритет 1 << 20, заведомо больше всего, что поставит игра.
Перерисовка из чужого кадра. Если попросить 3D-слой перерисоваться прямо из BridgeClock.update, в момент, когда Flutter собирает или раскладывает дерево, setState падает с «called during build». Поэтому мост откладывает перерисовку до конца кадра. Я долго считал, что за это приходится платить кадром отставания, и так и написал в документации. Это неправда: отложенный вызов только помечает 3D-слой, а следующий кадр начинается с тикеров, и тикер Flame успевает обновить игру раньше, чем Flutter дойдёт до сцены. Оба слоя рисуют одно и то же состояние. Если будете писать свой мост, откладывайте так же и не бойтесь рассинхрона.
Касание, которого не было. Когда появились охотники, оказалось, что они догоняют неподвижный корабль и спокойно висят на нём без единого удара. Мост тогда слушал корпус корабля. Корпус и бот оба твёрдые: решатель корабля останавливает его у поверхности бота, свиптест бота останавливает бота у поверхности корабля, и внутрь друг друга они не заходят. А мир столкновений сообщает о перекрытии, которого у двух касающихся твёрдых тел не бывает. Тараны при этом иногда срабатывали, потому что у летящего корабля перекрытие изредка оставалось. Лечится это триггером чуть больше корпуса: он не твёрдый, никакой решатель на нём не останавливается, и бот рядом с бортом его перекрывает. В примере с ящиком из showcase над полом стоит триггер по той же причине.
Одно тело на два хоста. Хотелось сделать бота и актором, и твёрдым телом. Не выйдет: CharacterController и RigidBody каждый регистрирует свой коллайдер и не читает состояние другого. Бот стал актором, а для физики корабля он всё равно обычное препятствие, потому что Dynamics.step проверяет тело против всего в мире столкновений, чем не владеет сам.
Знак у кватерниона. Quaternion.axisAngle(axis, θ) из vector_math строит обычный кватернион, и матрица узла поворачивает на +θ. А Quaternion.rotated(v) считает q̄·v·q и поворачивает вектор на -θ. Мост когда-то подбирал знак через rotated, и тесты, которые гоняли угол туда и обратно через rotationFor и angleFor, проходили: обе функции ошибались одинаково. А на плоскости пола поворот Flame рисовался зеркально, по часовой на экране становилось против часовой. На вертикальной плоскости две ошибки знака взаимно гасились, поэтому этого долго никто не видел. В 0.8.2 обе функции считают через матрицу, которой узел действительно рисуется, и тест проверяет направление на экране, а не только обратный ход. Если пишете свой мост, сверяйте поворот с тем, что нарисовано, а не с самим собой.
Последний бит у atan2. Угол, прочитанный обратно из поворота, может попасть в детерминированный шаг игры, а системная математика в последних битах отличается между Dart VM и браузером. Поэтому angleFor считает через Portable.atan2 из flutter3d_sim.
Чего пока нет
Flame всегда сверху. Слои не перемешиваются по глубине: 2D поверх 3D работает, HUD, карта и спрайты над сценой, а 3D-объект, который закрывает 2D-спрайт, сделать нельзя. Игра, где спрайт должен уходить за трёхмерную колонну, так не собирается.
Нет выбора 3D-объекта касанием. Тап приходит во Flame, а моста, который превратил бы его в луч в 3D-сцену, нет. В движке есть Raycaster, который бросает луч из координат виджета, но связывать его с Flame пока приходится руками.
Звук не связан. Во Flame есть flame_audio, во flutter3d свой flutter3d_audio, и мост между ними ничего не согласует. Выбирайте один.
И два ограничения, которые уже встречались выше: в колбэки Flame не приходят нормаль и глубина контакта, а масштаб камер согласован по соглашению, а не до пикселя, и только для ортографической камеры.
Попробовать
В showcase у каждого механизма своя страница с живой сценой и пошаговым руководством: два слоя без связи, перенос трансформов, акторы, физика и столкновения, ввод и камера. Начинать лучше с первой, где слои ещё не знают друг о друге: дальше каждая страница добавляет по одному мосту. Meteor Yard играется прямо в браузере, на странице демо. Там же управление, правила тарана и команды, чтобы собрать игру самому.
Если вы делаете что-то на Flame и хотите попробовать в нём 3D, заходите в r/flutter3d и рассказывайте, что не получилось. Исходники на GitHub, пакет flame_flutter3d на pub.dev.
Если эта публикация вас вдохновила и вы хотите поддержать автора — не стесняйтесь нажать на кнопку
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.