RTP DesportoSport CP conquista Supertaça feminina em râguebi frente ao SportingThe Jerusalem PostJerusalem approves protest tent for family of missing girl Haymanot KasauESPN DeportesGil Mora es baja de México para enfrentar a ChileESPN'Times Fly': Capitals' Ovechkin to retire at end of seasonColliderMarvel Officially Teases a “Chilling” New Fan-Favorite Ahead of ‘VisionQuest’ [Exclusive]VarietyHammer Films Boss John Gore on Resurrecting the British Horror Icon, Plans to Remake the Classics and Why Christopher Lee Was the ‘Greatest Dracula’BillboardWithout Kendrick Lamar — or Drake — How Will Hip-Hop Show Up In the Grammys’ Biggest Categories?Anime News NetworkBanG Dream! YUME∞MITA Anime Series ReviewDeadline‘I’m Doing My Job’ Trailer: Six Women ER Docs Combat Covid, Raise Kids In Aneri Shah’s Directorial DebutPopular ScienceApply to be a NASA flight directorThe Hollywood Reporter‘SNL UK’ Cast Member Al Nash, DaniBabes Among UTA Creators Roster Signings (Exclusive)VanguardI don’t have to speak perfect Yoruba to preserve Lagos culture - Rhodes-Vivour
The Daily Newsstand · Free, Always
Monday, October 5, 2026

Рендерим обучающие 3D‑ролики кодом на React и Three.js вместо съёмок и AI‑видео

Translate

Я делаю мобильное образовательное приложение. В какой‑то момент понадобилось объяснять пользователям сложные сценарии, где объекты двигаются по строгим формальным правилам, и объяснять не текстом, а видео. Любая неточная траектория или неверно анимированное действие на видео закладывает неверный паттерн. Я собрал для этого конвейер на Remotion и React Three Fiber.

Три варианта, которые не подошли

Съёмка на реальных объектах. Нужны десятки роликов на разные сценарии, и каждый требует отдельной постановки. Для одного человека без бюджета на продакшн это не вариант.

AI‑видео (Kling и подобные генеративные сервисы). Я прикинул экономику под нужный объём: десятки минутных роликов, а не один‑два на пробу. По моим подсчётам, на таком объёме подписка выходит в $100-150/мес, и это без перегенераций. А перегенерации будут: у генеративных моделей геометрия и физика откровенно плывут от кадра к кадру. Для контента, где неточность равна обучению неправильному действию, это неприемлемо.

3D‑моделлер вручную (так делает основной конкурент в нашей нише). Работает, но каждый новый сценарий съедает часы ручной анимации в Blender/Maya плюс платные ассеты. Один‑два человека такое не вытянут.

Идея: сцена как функция от номера кадра

Вместо таймлайна с ключевыми кадрами у меня компонент, где положение камеры, объектов и детали анимации считаются напрямую из frame, то есть из номера текущего кадра, который даёт Remotion. Mutable‑состояния между кадрами нет. Remotion может рендерить кадры не по порядку и в разных воркерах, поэтому единственный источник истины — чистая функция «frame в сцену».

Стек (версии из package.json, а не «последние на момент публикации»)

remotion              4.0.487
@remotion/three       4.0.487
@react-three/fiber    9.2.0
three                 0.178.0
react / react-dom     19.2.3
typescript            5.9.3

Remotion поднимает headless Chromium и на каждый кадр вызывает рендер React‑дерева (внутри WebGL‑канвас от @react‑three/fiber). Дальше он скриншотит канвас и отдаёт последовательность кадров в FFmpeg, который идёт в комплекте. FFmpeg упаковывает их в MP4 без промежуточной записи PNG на диск.

Ассеты я с нуля не рисовал, взял готовые CC0-паки: низкополигональные модели из Kenney (kenney.nl) и PBR‑текстуры поверхностей с ambientCG. Озвучка идёт через Piper TTS (офлайн, MIT‑лицензия, русские голоса rhasspy/piper‑voices). Для чернового этапа это бесплатно. Для финальной публикации планирую переехать на Yandex SpeechKit. У Piper через движок espeak‑ng физически нет ударений (юникод‑акут над гласной молча игнорируется на уровне фонем, я проверял), а для терминологии это иногда важно.

Часть рутины (сцены, скрипты сборки ассетов) и почти вся отладка багов, о которых ниже, шла в связке с Claude Code. Он работал агентом с доступом к файлам и терминалу: сам гонял рендер, читал логи и правил код. Статью по запросу я у него не заказывал. Панацеей он не был. Гипотезы он тоже предлагал неверные (см. историю с чернеющими текстурами), а диагноз мы поставили вместе, сравнив сломанный код с уже рабочим.

Ориентация объекта должна считаться из той же формулы, что и позиция

Если позицию движущегося объекта на кривой считать одной формулой, а его разворот (куда он «смотрит») отдельной, независимой интерполяцией угла, тело визуально «плывёт боком» вместо разворота по ходу движения. Ничто не гарантирует, что производная позиции и интерполированный угол совпадут в каждый момент. Рабочий паттерн — брать разворот прямо из производной кривой пути. Тогда он математически не может разойтись с фактическим движением.

// Кривая Эрмита с касательными по месту стыка фаз движения.
// yaw берётся из направления производной пути, без отдельной интерполяции угла,
// поэтому не может разойтись с фактическим смещением объекта.
const pos = hermitePoint(t, p0, p1, tangent0, tangent1);
const derivative = hermiteDerivative(t, p0, p1, tangent0, tangent1);
const yaw = Math.atan2(derivative.x, derivative.z);
actor.position.set(pos.x, pos.y, pos.z);
actor.rotation.y = yaw;

Баг, который стоил больше всего времени: чернеющие текстуры на длинном рендере

Самая неприятная находка конвейера. PBR‑текстуры (JPG, превращённый в THREE.Texture через обычную асинхронную загрузку) идеально рендерились на одном изолированном кадре (remotion still), но полностью чернели на последовательности из более чем 300 кадров. Хуже того, баг «заражал» даже давно стабильный, никак не связанный компонент, и портилось общее состояние рендера.

Я перепробовал по очереди шесть вариантов. Ни один не сработал сам по себе.

  1. Загрузка через img. Заменил на fetch и createImageBitmap.

  2. Компонент грузит текстуру заново на каждый кадр. Добавил модульный Promise‑кэш.

  3. Texture теряет привязку к ImageBitmap. Обернул в CanvasTexture, паттерн один в один с уже рабочим кодом другого компонента.

  4. Не хватает GPU‑памяти. Уменьшил текстуры с 1024px до 512px.

  5. GPU‑текстура не готова к моменту захвата кадра. Добавил явный gl.initTexture().

  6. Сетевой fetch к dev‑серверу Remotion конфликтует под нагрузкой. Заменил на base64 data: URI.

Все шесть попыток крутили механизм асинхронной загрузки и оставляли саму асинхронность на месте. Стабильно работали только процедурные canvas‑текстуры, которые собирались полностью синхронно внутри useMemo, без единого await в цепочке.

Фикс получился простой: убрать асинхронность совсем.

// На этапе СБОРКИ (Node.js и PIL, не в браузере) JPEG раскодирован
// в сырые RGBA-пиксели, потом в base64 и в константу в отдельном .ts-файле.
// В рантайме только это, без единого await:
const decode = (b64) => {  const bin = atob(b64);  const arr = new Uint8Array(bin.length);  for (let i = 0; i < bin.length; i++) arr[i] = bin.charCodeAt(i);  return arr;
};
const tex = new DataTexture(decode(rawB64), w, h, RGBAFormat, UnsignedByteType);
tex.needsUpdate = true; // ни одного Promise на всём пути

Предполагаю, что на затяжном рендере Remotion не всегда переигрывает кадр, если асинхронный setState с текстурой прилетает уже после первого синхронного прохода. Материал успевает закрепиться пустым. delayRender и continueRender вместе с асинхронной загрузкой честно работают для одного кадра (still), но для многокадрового рендера гарантии нет. Поэтому любой ресурс, нужный дольше одного кадра, я теперь грузу синхронно на этапе сборки, а не в рантайме браузера.

Ещё несколько находок покороче

Смещение объекта относительно осевой линии сцены оказалось развёрнуто в противоположную сторону. В одном конкретном компоненте формула поворота вектора движения была развёрнута не туда. Геометрию камеры я с нуля передоказывать не стал, а сверил код с уже одобренным и проверенным на практике куском в другом месте проекта.

Перекраска одной и той же 3D‑модели в разные цвета не работает простым умножением material.color, если у базовой текстуры уже насыщенный цвет. Насыщенный участок остаётся тем же цветом поверх любого тинта. Пришлось перекрашивать саму canvas‑текстуру попиксельно по маске: насыщенный и не тёмный пиксель считается целевой деталью.

Цифры

Железо обычное, домашняя GTX 1650.

Ролик: 1620 кадров (54 сек при 30 FPS), 1080p
Полное время рендера в MP4: около 52 сек (/usr/bin/time -v, Elapsed)
Пиковая память процесса: около 774 МБ
Объектов окружения на сцене: около 250 (детерминированный процедурный scatter)
Стоимость инфраструктуры: 0 руб, рендер идёт локально, без облачного GPU и без AI-подписок

Рендер идёт примерно в реальном времени, 1 к 1 (без учёта FFmpeg‑мультиплексирования, оно параллельно и заметного времени не добавляет). У Kling AI и аналогов время и стоимость растут линейно с минутами видео и числом перегенераций из‑за артефактов. У меня предел задают мои часы на код и рендер‑бюджет собственного железа.

Что получилось и что дальше

Конвейер рабочий, новые ролики я уже делаю на нём. Готовой коробкой его назвать нельзя: баги я продолжаю находить и чинить (последний из описанных выше свежий), и по стилю окружения арт‑дирекшн‑вопрос пока открыт.

Зато для нового сценария ничего не нужно переснимать или перерисовывать. Я меняю только входные параметры сцены (координаты, тайминги, реплики), а геометрия и физика движения остаются в одной и той же кодовой базе. Если понадобится более киношная картинка, поверх текущей архитектуры добавятся шейдеры и пост‑обработка, переходить на другой инструмент не придётся. Сейчас я делаю сглаживание, контактные тени/AO и более фотореалистичные материалы окружения. Бюджет рендера это позволяет, упираюсь только в своё время.

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.