Bollywood HungamaEXCLUSIVE: CBFC clears Drishyam The Conclusion WITHOUT a single cut; is 8 minutes longer than Drishyam 2ESPNThe only MLB playoff preview you need: World Series odds, likely MVPs and how far all 12 teams will goThe Jerusalem PostTeenager wounded in Holon after explosive device detonates, police launch investigationESPN DeportesIrlanda negó el saludo a Israel y goleó por 3-0PunchTerrorists attacking Plateau communities observing curfew, NBA allegesRTP DesportoAPAF exige medidas após agressão a árbitroEgypt IndependentProposal to harvest organs from Egypt’s death-row inmates sparks controversyWirtualna Polska"To już oficjalne". Trzaskowski komentuje koniec zbiórki ws. referendumSouth China Morning PostChild and 2 women ‘crushed to death’ during France to UK sea crossingThe South AfricanGetting your driver’s licence? Waiting times at every testing centre in Cape Town20 MinutenWohnung zu heiss: Ueli Schmezer fordert Miet-RabattDW DeutschWadephul gibt Strafgerichtshof nach US-Attacke Rückendeckung
The Daily Newsstand · Free, Always
Monday, September 28, 2026

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

Translate

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

Продолжение статьи «Свет в конце пайплайна: 3D-движок на чистом Dart (+gpu)».

Этот вопрос после прошлой статьи задавали чаще всего. Начиная с flutter3d 0.7 для ответа на него есть пакет flame_flutter3d, мост между двумя движками.

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

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

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

Ящик упал в 3D-сцене, в углу та же сцена на карте Flame, площадка позеленела на обоих слоях

Ящик упал в 3D-сцене, в углу та же сцена на карте Flame, площадка позеленела на обоих слоях

Быстрый старт

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

Минимальный пример: HUD Flame сверху, голубой кубик, которым управляет Flame, ящик сел на площадку, и Flame это услышал

Минимальный пример: HUD Flame сверху, голубой кубик, которым управляет 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, куб вращает flutter3d

Два слоя без связи: оранжевый квадрат двигает Flame, куб вращает flutter3d

Один цикл событий

Главное решение в мосте касается часов. У Flame уже есть тикер, синхронизированный с vsync. Если завести второй для flutter3d, два таймера рано или поздно разойдутся. Я не знаю надёжного способа это исключить, кроме как не заводить второй, поэтому часы в гибридной игре одни, и это часы Flame.

Виджет добавляет в игру обычный компонент BridgeClock. Каждый кадр, после того как обновились все остальные компоненты, он вызывает onTick(dt) с тем же dt, который получил Flame, и оттуда шагает всё, что есть на стороне flutter3d. Кадр гибридной игры выглядит так:

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

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

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

  4. 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, оранжевый двигает Flame и читает сцена; справа их точки на карте Flame

Два куба: синий двигает сцена и читает Flame, оранжевый двигает Flame и читает сцена; справа их точки на карте Flame

Столкновения

Столкновения считает трёхмерная физика, а узнать о них хочется во Flame, привычным способом. CollisionBridge вешается слушателем на коллайдер flutter3d и пересылает его события в CollisionCallbacks Flame-компонента. Тут два несовпадения, и оба я решил не замазывать.

Первое: flutter3d сообщает о паре коллайдеров, а Flame ждёт PositionComponent. Физика про Flame ничего не знает, коллайдер не носит с собой компонент, поэтому найти второй компонент должен сам вызывающий через resolveOther. Если тот вернул null, потому что с той стороны стена уровня или тело без Flame-компонента, мост не вызывает ничего. Придумать компонент или отдать null значило бы сказать Flame-коду неправду: что он столкнулся с тем, чего с его точки зрения не существует.

Второе: в колбэке Flame есть только набор точек пересечения, места для нормали и глубины там нет. Мост отдаёт одну точку, середину между центрами двух коллайдеров, спроецированную на плоскость компонента. Для двух одинаковых коробок это центр перекрытия, а у коробок разного размера точка может оказаться и снаружи, так что считайте её приблизительной. Настоящие нормаль и глубина остаются доступны на стороне flutter3d.

Ввод и камера

FlameInputBridge принимает клавиатуру и перетаскивание из Flame и пишет их в те же Bindings и InputState, что и нативный ввод flutter3d. Своей таблицы клавиш у моста нет. Если игрок переназначил прыжок в меню, переназначение должно работать и в сборке с Flame, а две разошедшиеся таблицы со стороны неотличимы от переназначения, которое молча не сработало. Геймпад опрашивает PadInput и пишет туда же.

Клавиша, принятая Flame, двигает шар во flutter3d

Клавиша, принятая Flame, двигает шар во flutter3d

На телефоне клавиш нет, и тут общий ввод особенно выручает. Виртуальный джойстик это обычный 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-сборка), а код, который ведёт корабль, ради него не менялся.

Meteor Yard на Android: виртуальный джойстик Flame в углу, корабль и боты в 3D

Meteor Yard на Android: виртуальный джойстик Flame в углу, корабль и боты в 3D

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

Жёлтые кольца рисует Flame через свой видоискатель, кубы рисует flutter3d, и они совпадают

Жёлтые кольца рисует Flame через свой видоискатель, кубы рисует flutter3d, и они совпадают

Игра по шагам

Разберу 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); // патруль
Акторы flutter3d_sim ходят в 3D, а Flame читает их положения на карте

Акторы flutter3d_sim ходят в 3D, а Flame читает их положения на карте

Шаг 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 (на Android EnableFlutterGPU);

  • игра наследует 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.

Если эта публикация вас вдохновила и вы хотите поддержать автора — не стесняйтесь нажать на кнопку

View the original on Хабр →

KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.