Шутка «всё перепишут на Rust» добралась до мультиков. 9 ноября вышел raea, Render Anything Engine: сцены описываются в JSX, а рисует их игровой движок.

Сама идея делать анимации кодом давно работает в Remotion: React-компоненты задают содержимое кадров, браузер их отрисовывает. raea связывает тот же React с Bevy, игровым движком на Rust. У него уже есть инструменты для 3D: геометрия, материалы, освещение, камеры и анимация.

Я увидел проект через два дня после релиза и сразу полез тыкать. Привычными компонентами управлять игровым движком? Такое я пропустить не мог. Чтобы разобраться, как это устроено, проследил путь одного куба от JSX до готового изображения.

Куб, описанный на React и отрендеренный через Bevy
Куб, описанный на React и отрендеренный через Bevy

Пишем куб на React

Работа с кадрами знакома по Remotion. Функция useCurrentFrame() возвращает номер кадра, а по нему вычисляются высота и угол поворота куба. JSX служит разметкой сцены: компонент описывает 3D-объект, как в интерфейсе описывал бы кнопку.

Вот основная часть нашего Cube:

const frame = useCurrentFrame();
const y = Math.abs(Math.sin((frame / 30) * Math.PI));
const angle = (frame / 60) * Math.PI;

return (
  <Mesh3d
    name="cube"
    transform={Transform.fromXyz(0, 0.5 + y, 0).withRotation(
      Quat.fromEuler("XYZ", 0, angle, 0),
    )}
  >
    <Cuboid half_size={[0.5, 0.5, 0.5]} />
    <StandardMaterial base_color="#c4f82e" />
  </Mesh3d>
);

Формула для y задаёт прыжок, angle отвечает за вращение. Mesh3d объединяет форму куба (Cuboid), его материал (StandardMaterial) и положение в пространстве (Transform). Камера и свет тоже задаются компонентами.

На кадре 15 центр куба должен оказаться на высоте 1,5 метра, а угол поворота составить 45°. Эти числа пригодятся, чтобы проверить, что добралось до движка.

Как React добирается до изображения

При стандартном рендере Remotion открывает композицию в Chrome без видимого окна. Браузер рисует страницу, Remotion забирает каждый кадр, а FFmpeg, программа для обработки видео, собирает ролик.

raea обходится без браузера. Встроенная среда выполняет JavaScript, React вычисляет параметры объектов, а Rust-часть программы по ним строит сцену. Сам React-код в Rust не переводится: между ними передаются данные.

Путь кадра от React до видео в Remotion и raea
Путь кадра от React до видео в Remotion и raea

1. JSX превращается в JavaScript

Наш файл имеет расширение TSX: это TypeScript с JSX-разметкой. Сначала raea обрабатывает его сборщиком Rolldown. Тот объединяет код с зависимостями и преобразует его в JavaScript, готовый к выполнению. Запись <Mesh3d ...> становится вызовом функции с параметрами. Результат можно посмотреть командой raea bundle.

Внутри Mesh3d обнаруживается обычный div. Так raea записывает описание объекта в дерево React:

function node3d(kind, props, children, extra) {
  return jsx("div", {
    "data-3d": kind,
    "data-props": JSON.stringify(serialisable(props)),
    ...extra,
    children,
  });
}

Этот div служит контейнером данных, а не элементом веб-страницы. В data-3d записан тип 3D-узла, в data-props его параметры, включая положение и поворот. JSON.stringify упаковывает их в строку для передачи в Rust.

2. Свой рендерер передаёт изменения в Rust

React описывает нужные элементы, а рендерер связывает их с конкретной средой. В браузерном приложении этим занимается react-dom: создаёт и обновляет элементы страницы.

У raea свой рендерер на библиотеке react-reconciler. Он получает изменения дерева React и превращает их в команды для Rust. Пиксели на этом шаге ещё никто не рисует.

При первом кадре нужно создать узлы и связать их между собой. Дальше достаточно обновлять изменившиеся свойства, или пропсы. В исходниках этот обмен называется wire, «проводом». По нему идут команды create (создать), append (добавить дочерний узел) и update (обновить свойства).

У нашего куба меняются высота и поворот. Именно эти изменения рендерер передаст дальше.

3. Rust собирает сцену

Bevy хранит объекты по модели ECS, entity-component-system. У каждой сущности есть идентификатор и набор компонентов с данными: например, положением, геометрией и материалом. «Компонент» здесь означает набор данных, не React-функцию.

Rust-часть raea принимает команды и создаёт в Bevy сущности для узлов React. Их свойства сохраняются в компоненте ReactProps. В обработчике create это выглядит так; фрагмент сокращён:

let e = commands
    .spawn((
        ReactNode(id("id").unwrap_or(0)),
        ReactKind(op["kind"].as_str().unwrap_or("").into()),
    ))
    .id();
fresh_props.insert(e, p);
ents.by_id.insert(id("id").unwrap_or(0), e);

Вызов spawn создаёт сущность, id() возвращает её идентификатор. Следующие строки сохраняют пропсы и связь с исходным React-узлом.

Затем функция read3d достаёт из ReactProps знакомые атрибуты data-3d и data-props. Rust получает описание нашего куба: форму, положение и поворот.

4. Bevy рисует кадр

Чтобы из описания получилась картинка, нужна геометрия. Меш (mesh) задаёт поверхность объекта вершинами и треугольниками. Вызов Mesh::from(Cuboid::new(w, h, d)) создаёт меш коробки нужных размеров.

Положение и поворот записываются в Transform объекта Bevy. В ветке обновления есть такие строки:

let Some(nt) = place(e) else { continue };
if *t != nt && !driven {
    *t = nt;
}

t здесь текущий Transform, nt новый. Строка *t = nt применяет изменение. Так значения из нашего React-компонента доходят до объекта в игровом движке.

У Bevy теперь есть всё для отрисовки: геометрия, материал, положение объекта, камера и свет. Движок рассчитывает вид сцены с выбранного ракурса и рисует кадр на видеокарте. Из последовательности таких кадров raea кодирует видео.

Проверим куб на кадре 15:

raea scene explain cube.tsx --frame 15 --json

Команда вернула границы: по высоте от 1 до 2 метров, по X и Z от −0,7071 до +0,7071. Центр оказался на высоте 1,5 метра, а размеры по горизонтали соответствуют повороту на 45°. Всё сошлось с формулами из JSX.

Разбор сделан по исходникам raea 0.1.1-rc4. Полный пример с камерой и светом:

Полный TSX-файл с кубом
import { AbsoluteFill, Composition, registerRoot, useCurrentFrame } from "raea";
import { Camera3d } from "raea/camera";
import { DirectionalLight } from "raea/light";
import { Cuboid, Mesh3d } from "raea/mesh";
import { StandardMaterial } from "raea/pbr";
import { Quat, Transform } from "raea/transform";

function Cube() {
  const frame = useCurrentFrame();
  const y = Math.abs(Math.sin((frame / 30) * Math.PI)); // прыжок раз в секунду
  const angle = (frame / 60) * Math.PI; // пол-оборота в секунду

  return (
    <AbsoluteFill style={{ background: "#f1f1ef" }}>
      <Camera3d
        transform={Transform.fromXyz(0, 1.5, 5).lookingAt([0, 0.6, 0])}
      />
      <DirectionalLight
        transform={Transform.fromXyz(2, 4, 3).lookingAt([0, 0, 0])}
        illuminance={10000}
      />
      <Mesh3d
        name="cube"
        transform={Transform.fromXyz(0, 0.5 + y, 0).withRotation(
          Quat.fromEuler("XYZ", 0, angle, 0),
        )}
      >
        <Cuboid half_size={[0.5, 0.5, 0.5]} />
        <StandardMaterial base_color="#c4f82e" />
      </Mesh3d>
    </AbsoluteFill>
  );
}

registerRoot(() => (
  <Composition
    id="cube"
    component={Cube}
    width={1080}
    height={1080}
    fps={30}
    durationInFrames={60}
  />
));

Теперь вместо куба человек и кофе

На кубе удобно разбирать устройство движка. Сложные сцены уже хочется поручать AI-агенту: он пишет React, запускает рендер и правит результат по замечаниям. Вручную расписывать комнату и движения персонажа я бы не стал, такого запаса терпения у меня нет.

Поэтому дал Claude Code задачу: человек заходит в комнату, садится за стол, тянется к мышке и опрокидывает кофе на ноутбук. После нескольких правок получилось так:

Сцена после исправления проходки и касания чашки
Сцена после исправления проходки и касания чашки

Сценой управляет один TSX-файл, собранный агентом. Blender я не открывал, Rust не писал. Для человека и жидкости в raea есть дополнительные инструменты.

Модель человека называется Anny. Движения взяты из базы CMU: это мокап, записи движений людей. В коде задаются маршрут до стула и посадка, а motion matching подбирает подходящие кадры из библиотеки. Для руки можно отдельно указать цель. Обратная кинематика, IK, подбирает положение суставов, чтобы ладонь до неё дотянулась.

Кофе задан компонентом <Goo solver="sph">. Он подключает симуляцию жидкости SPlisHSPlasH: raea заранее рассчитывает её и воспроизводит по кадрам. Стол и ноутбук участвуют в столкновениях с жидкостью, а траектория кружки берётся из кода сцены.

Внешний вид тоже переключается компонентом: <Look mode="cartoon" /> добавляет мультяшный контур, paint даёт плоскую иллюстрацию. Один кадр с разными настройками:

Один кадр в режимах Cartoon, Paint и без Look
Один кадр в режимах Cartoon, Paint и без Look

Человек ехал, а чашка падала сама

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

Разделили клипы ходьбы и стойки, добавили поворот и остановку из CMU, настроили подбор и уменьшили подтяжку. Вот первые три секунды до и после:

Слева персонаж скользит к столу, справа делает шаги и останавливается перед посадкой
Слева персонаж скользит к столу, справа делает шаги и останавливается перед посадкой

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

Удар рукой здесь не рассчитывается физикой. Опрокидывание кружки задано ключевыми кадрами и согласовано с движением кисти. Жидкость уже следует за кружкой по результатам симуляции.

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

На чём это запускать

Сцену с кофе рендерил на MacBook Pro с M3 Pro. Получился ролик 1920×1080, 30 fps, чуть больше девяти секунд.

Пробовал и Linux без GPU, с программным Vulkan. Простой куб там считался примерно по 0,18 секунды на кадр. Один кадр студийной сцены с тенями вместе с запуском занял уже 28 секунд, поэтому сложный ролик делал на маке.

Для подготовки тела и переноса движений понадобились Python, bun и инструменты из исходников raea. Это опыт с версией 0.1.1-rc4: проект только вышел и быстро меняется. Для коммерческого использования стоит отдельно прочитать условия FSL-1.1.

А дальше можно дать зрителю управление

У авторов в плане экспорт в браузер. В исходниках уже есть работа над WebAssembly-сборкой, вариантом движка для запуска на веб-странице. Готовой команды экспорта в 0.1.1-rc4 ещё нет.

Если её доведут, сцену с кофе можно будет превратить в маленькую игру: дать зрителю пройти к столу, потянуться к мышке и попробовать не залить ноутбук. Заодно проверим, можно ли вообще сесть за этот стол без происшествий.