Введение

Честно говоря, я долго не мог решиться написать и опубликовать эту статью. Зачем, думал я, возиться с не самой популярной технологией и изобретать велосипед — реализовывать функции, которые уже где‑то есть? На этот вопрос у меня нет универсального ответа — каждому своё.

Сначала мне казалось, что рассказывать о таких «подвигах» не слишком интересно. Все любят истории об успешном успехе. Потом я вспомнил: главное — не итог, а путь, опыт и знания, которые ты получаешь по дороге. Как только я начал смотреть на материал как на обучающий, делиться им стало намного проще.

Бывает так: с какой то технологией уже разобрался, а вот перейти к новой боязно. Учить новые движки непросто, да и текущий инструмент уже не справляется с задумкой… Сомнения часто мешают двигаться вперёд, но народная мудрость «глаза боятся, а руки делают» никогда не подводит.

В итоге я решился и попробовал FXGL для 3D‑рендеринга. Но не для того, чтобы сделать полноценную игру(хотя она и получилась), а чтобы соединить расчёты по системному моделированию с элементами геймификации. Уточню: я не призываю использовать FXGL во всех случаях. Для серьёзных 3D‑проектов есть отличные инструменты — Unigine, jMonkeyEngine, Godot, Unreal Engine. Я попытался собрать и упорядочить знания, которые получил в ходе своего небольшого эксперимента.

 

Сравнение с легендарной Need for Speed: Porsche Unleashed (2000)
Сравнение с легендарной Need for Speed: Porsche Unleashed (2000)

С чем имеем дело или из чего собрать Франкенштейна

У JavaFx в 3D есть некоторые плюсы и минусы, в этом разделе попытаюсь их про них рассказать.

Управление камерой

JavaFX 3D не просто даёт какую‑то камеру она под вашим полным контролем. Что можно делать:

  • настраивать поле зрения (FOV — Field of View), как в шутерах: хотите широкий обзор — увеличиваете, хотите «зумировать» — уменьшаете;

    Можно управлять параметром налету, используя биндинги

camera3D.getPerspectiveCamera().fieldOfViewProperty().bind(cam_val);
  • регулировать ближнюю и дальнюю плоскости отсечения (near/far clipping planes) — это как фокус в фотоаппарате: отсекаем лишнее, чтобы не нагружать рендер ненужными объектами;

  • двигать и вращать камеру в 3D‑пространстве с помощью простых трансформаций (Translate, Rotate) — всё как в настоящем движке;

Вот, например, как привязать камеру к автомобилю

var distanceToCamera = -8;
 var pos = entity.getPosition3D().subtract(entity.getTransformComponent().getDirection3D().multiply(distanceToCamera));
 
 
 transform.setPosition3D(pos);
 transform.lookAt(entity.getPosition3D());
 transform.setY(entity.getY() + CAMERA_HEIGHT_OFFSET);

И если захочется добавить кинематографичности, то можно добавить «сглаживания» :

        double smoothXY = 0.2;
         double smoothZ = 0.2;

        var distanceToCamera = -8;
        var targetPos = entity.getPosition3D().subtract(
                entity.getTransformComponent().getDirection3D().multiply(distanceToCamera)
        );

// Интерполяция по осям с разными коэффициентами
        double newX = cameraPos.getX() + (targetPos.getX() - cameraPos.getX()) * smoothXY;
        double newY = cameraPos.getY() + (targetPos.getY() - cameraPos.getY()) * smoothXY;
        double newZ = cameraPos.getZ() + (targetPos.getZ() - cameraPos.getZ()) * smoothZ;

        cameraPos = new Point3D(newX, newY, newZ);

        transform.setPosition3D(cameraPos);
        transform.lookAt(entity.getPosition3D());
        transform.setY(cameraPos.getY() + CAMERA_HEIGHT_OFFSET); 

 

Или делать интерактивное управление: например, реализовать вид от первого лица или камеру, которая красиво вращается вокруг объекта. Типа такого.

Применение материалов типа PhongMaterial с поддержкой diffuseMap и normalMap

PhongMaterial — это билет в мир визуально приятных поверхностей. Разберём, что тут есть:

  • diffuseMap (диффузная карта) — задаёт основной цвет и текстуру. Проще говоря, это «обёртка» объекта: хотите дерево — клеите текстуру дерева, хотите камень — камень;

  • normalMap (карта нормалей) — магия, которая создаёт иллюзию мелких деталей без увеличения полигонов. Поверхность кажется рельефной, хотя на деле остаётся гладкой — экономия ресурсов налицо. И тут немного про вайб-кодинг. Решил попробовать сгенерировать скрипт с помощью Алисы для созданий кары нормалей для всех изначальных текстур, и результат вышел вполне рабочим. Да, правильнее, наверное, поработать с каждой текстурой в профессиональном редакторе, но это уже совсем другой пилотаж.

  • ещё есть specularMap — с ним можно контролировать, где и насколько сильно будут бликовать участки модели. Хотите, чтобы часть объекта блестела, как металл, а часть оставалась матовой? Легко;

  • коэффициент блеска (specularPower) — настраиваете, и вот уже ваша модель может быть как матовым пластиком, так и зеркальным хромированным шаром.

Импорт 3D‑моделей в форматах .obj, .3ds и других

Какие форматы поддерживаются и что потом с ними делать:

  • загружать готовые модели в популярных форматах — .obj (Wavefront), .3ds (3D Studio), а с дополнительными библиотеками и другие форматы подтянуть можно;

  • сохранять иерархию объектов и трансформации, которые вы задали в редакторе 3D‑моделирования — всё перенесётся как надо;

  • автоматически создавать JavaFX‑узлы (MeshView, Group) для импортированной модели. То есть вы загрузили объект — а он уже готов к использованию в сцене, без лишних телодвижений.

Интеграция пользовательских GLSL‑шейдеров через низкоуровневые API

  • Для тех, кому стандартных возможностей мало, есть доступ к «внутренностям» рендеринга:

  • пишите свои вершинные (vertex) и фрагментные (fragment) шейдеры на GLSL — и вуаля, у вас полный контроль над тем, как выглядит каждый пиксель;

  • можете заменить стандартное освещение Фонга на что‑то своё — например, сделать неоновое свечение, стилизованную графику в духе комиксов или эффект «под водой»;

  • работайте с текстурами и атрибутами вершин внутри шейдеров — создавайте процедурные эффекты прямо на лету;

  • подключаете через ShaderProgram и PhongMaterial — и стандартный пайплайн уже ваш, можно его подменять и творить что душе угодно.

Ключевые ограничения JavaFX 3D

Отсутствие реализации алгоритмов теневого отображения (shadow mapping)

Тут всё просто: теней нет. Совсем. Ни динамических, ни статических — оно и понятно, ведь это не игровой движок. Что это значит на практике:

  • объекты не отбрасывают тени друг на друга и на пол — выглядит это, мягко говоря, плоско и неестественно;

  • хотите простую тень под персонажем — придётся вручную рисовать полигон и приделывать её к модели. Да, вот так топорно;

  • динамические тени, которые так любят все современные игры, тут недоступны без танцев с бубном и низкоуровневых OpenGL‑вызовов. А это уже убивает весь кайф от использования высокоуровневого фреймворка.

Ограниченная и не интуитивная модель работы с источниками света

Освещение тут — это отдельная песня(смотри видео). Что не так:

  • выбор источников света скудный: точечный (PointLight), направленный (DirectionalLight), рассеянный (AmbientLight) — и всё. Прожектор (Spotlight)? Забудьте. Хотите подсветить сцену как в театре? Придётся изобретать велосипед;

  • настройки света какие‑то «деревянные»: интенсивность, радиус затухания, цвет — всё работает не так гибко, как хотелось бы;

  • освещение считается для вершин (per‑vertex lighting), а не для фрагментов (per‑fragment lighting). На низкополигональных моделях это даёт заметные артефакты и выглядит не очень реалистично по сравнению с современными движками.

Отсутствие встроенного физического движка

Физика? Какая физика? Её тут просто нет. Да и откуда ей взяться, только рендеринг только хардкор. Что это влечёт за собой:

  • никакой автоматической проверки столкновений (collision detection) — объекты будут проходить друг сквозь друга, как привидения;

  • гравитация, инерция, трение, отскоки — всё это нужно писать с нуля или искать сторонние библиотеки. Хотите, чтобы мяч отскакивал от пола? Придётся потрудиться;

Отсутствие WYSIWYG‑редактора сцен

Здесь всё по‑старинке: никакой визуальной магии, только код. Что это значит:

  • вся сцена — иерархия узлов (Group, SubScene, MeshView, Light, Camera) — создаётся и настраивается исключительно кодом на Java. Никаких drag‑and‑drop, никаких визуальных редакторов;

  • хотите поставить куб в угол комнаты? Пишите код. Повернуть лампу? Снова код. Настроить освещение? Вы уже поняли;

Однако, чтобы работалось слегка комфортнее, я добавил окна настройки для источников света и дерево объектов на сцене. Мелочи, но все же приятные. И да, на вайб кодить такие окна настроек тоже можно. Главное, потом проверить за ИИ помощником)

Окна настроек источников света
Окна настроек источников света

Собираем нашего монстра

Одна из основных идей этого хобби и обучающего проекта, создавать графические кроссплатформенные приложения на Java, где «под капотом» работают математические модели из платформы системного моделирования.  К этой идее подтолкнули пользователи, которые хотели бы обмениваться моделями в виде закрытых приложений с графическим интерфейсом, при этом без браузерного интерфейса.

В платформе, итак, есть задокументированное API, а можно избавиться от накладных расходов подключив библиотеку к приложению.

Собственно, для этого нужно подключить в ваш проект библиотеку RepeatCore любым удобным способом, и в классе контроллера, предварительно скачав свой проект модели с сайта приложения. 

 RepeatCore.parseClassMap();          //Настраиваем библиотеку для работы в фоновом режиме
 RepeatCore.runtimeAsService = true;
 RepeatCore.isSingle = true;
 RepeatCoreService.runtimeAsService = true;
 RepeatCoreServiceDocument.verboseLevel = 0;
 RepeatCoreServiceDocument.startWithoutBinFile = true;
 var repeatCoreService=new RepeatCoreService();
 var tempdoc = new RepeatCoreServiceDocument();
 serviceDocument = tempdoc.openDocument(filename); // файл вашего проекта
 serviceDocument.setParentRepeatCoreService(repeatCoreService);
 serviceDocument.documentCalculate();

Далее, проинициализировать объекты для управления(входные параметры для модели).

speed_setpoint=(Constant) getObjbyIdFromList(serviceDocument.componentList,"1739110251130"); // это уникальный ID объекта, отображается на схеме

И затем, обновлять данные на графиках, диаграммах и других контролах любым способом, используя биндинги или отдельный поток.

Собственно, вот такое простенькое приложение можно получить, для управления электроприводом

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

Франкенштейн оживает

Признаюсь: я просто не смог устоять. Наткнулся на классную статью про перенос карт из старых NFS — и загорелся идеей сделать что‑то похожее. А поскольку NFS 5 для меня — это не просто игра, а настоящая любовь с первого заезда, выбор карты был очевиден.

Взял ассеты из NFS 5 и решил перенести их в FXGL. Звучит просто, да? На деле же это обернулось миллиардами (ну, может, десятками) итераций:

  • разбирался, как вообще эти модели устроены;

  • мучился с масштабом — то машина размером с дом, то дорога в три метра шириной. Об этом автор статьи про перенос в UE тоже столкнулся;

  • настраивал освещение так, чтобы карта не напоминала тёмный подвал(Хотя может и получилось своеобразно).

Итак, по порядку.

Чтобы загрузить карту подгружаем библиотеку jimObjModelImporterJFX и загружаем модель, предварительно создав для нее Entity в фабричном классе.

@Spawns("city")
public Entity newCity(SpawnData data) {

Group modelRoot = new Group();

var importer = new ObjModelImporter();
importer.read("nfs5_industrial_grouped.obj"); // Загружаем модель в формате .obj, можно в .fbx,.3ds
for (MeshView view : importer.getImport()) {
modelRoot.getChildren().addAll(view); // здесь можно поиграться с материалами мешей, загрузить карты нормалей и т.д.
}
importer.clear(); // убираем за собой
importer.close();

// Спауним Entity карты
var e = entityBuilder(data)
        .view(modelRoot)
        .build();
return e;
}

Собственно, аналогично спауним и модель автомобиля, задав ей класс-компонент (он отвечает за анимации, физику и т.д.)

@Spawns("obj")
public Entity newObj(SpawnData data) {


    Group modelRoot = new Group();

    var importer = new ObjModelImporter();
    importer.read("911_92.obj");
var e = entityBuilder(data)
        .view(modelRoot)
        .with(new CarComponent())
        
        .build();
e.setScaleUniform(1);

return e;
}

Добавляем HUD. В main-классе игры добавляем свои элементы в метод initUI(). Я использовал пару собственных классов для графиков и gauge. Выглядит это примерно так

@Override
protected void initUI() {


    super.initUI();


    CarComponent carComponent = car.getComponent(CarComponent.class);
    carSettingsWindow = new CarSettingsWindow(carComponent);

    torque_chart = new LineChart(new NumberAxis(), new NumberAxis());
    CustomLineChart.setup(torque_chart,"Скорость, км/ч");
    speedOmeter = new GaugeComponent("км/ч","Скорость",0,300,350,350);
    RPMmeter = new GaugeComponent("об/мин","",0,9000,350,350);
    gearratio = new GaugeComponent("","Передача",0,6,250,250);
    torque_gauge = new GaugeComponent("Н*м","Момент",0,1500,250,250);
    rpm_chart = new LineChart(new NumberAxis(), new NumberAxis());
    xy_chart = new LineChart(new NumberAxis(), new NumberAxis());
    gear_chart = new LineChart(new NumberAxis(), new NumberAxis());
    CustomLineChart.setup(xy_chart,"Траектория");
    CustomLineChart.setup(gear_chart,"Передача");
    CustomLineChart.setupMultipleSeries(rpm_chart,"Об/мин");
    CustomLineChart.setupMultipleSeries(rpm_chart,"Момент, Н*м");

    // Создаем корневую панель BorderPane для разделения на зоны
    BorderPane root = new BorderPane();
    root.setPrefHeight(FXGL.getAppHeight());
    root.setPrefWidth(FXGL.getAppWidth());
    // Верхняя часть (графики)
    HBox topPane = new HBox();
    topPane.setSpacing(10);
    topPane.setPadding(new Insets(5, 5, 5, 5));

    topPane.getChildren().addAll(rpm_chart, torque_chart, gear_chart, xy_chart);
    root.setTop(topPane);


    var hboxLeft = new HBox();
    hboxLeft.getChildren().addAll(torque_gauge.gauge,speedOmeter.gauge);
    hboxLeft.setSpacing(10);
    hboxLeft.setPadding(new Insets(5, 5, 5, 5));
    var hboxrRight = new HBox();
    hboxrRight.getChildren().addAll(RPMmeter.gauge,gearratio.gauge);
    hboxrRight.setSpacing(10);
    hboxrRight.setPadding(new Insets(5, 5, 5, 5));
    root.setLeft(hboxLeft);
    root.setRight(hboxrRight);
    getGameScene().addUINode(root);

Про пост эффекты, блюры и всякие блумы. Их можно применить к 2D узлу, но к 3D никакого эффекта не будет. А что если попробовать?

В общем, если наложить эффект на субсцену, то эффект может быть интересным. Ниже пример с легким motion blue эффектом.

Про нейронки, апскейлинг текстур, добавление карт нормалей – и картинка станет лучше. Что-то я попробовал – но возиться в Blendere и других инструментах – увольте, мне это не очень интересно)

 

И вот как выглядит Франкенштейн
И вот как выглядит Франкенштейн

А как же не упомянуть производительность и железо — вот на таком ноутбуке с AMD Ryzen 5 7430U with Radeon Graphics (2.30 GHz) и 16 ГБ памяти

 я провожу эксперименты  над своим Франкенштейном и 60 FPS стабильно выдерживаются.

Скрин с графиком и выкладками из встроенного профилировщика.

Скрытый текст

Для дополнительного профилирования вот строчка с опциями для JVM -Dprism.verbose="true" -Dprism.vsync="false" -Djavafx.animation.fullspeed="true"

Результаты бенчмаркинга
Результаты бенчмаркинга

Заключение

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

Что я понял на практике:

  • визуализация расчётов — это быстро и доступно даже без продвинутых инструментов;

  • HUD‑интерфейс собирается из готовых библиотек буквально за полчаса, главное определить дизайн и концепцию;

Начав с «неподходящего» на первый взгляд инструмента (JavaFX в 3D), можно создать рабочий прототип фреймворка для аркадных гоночных симуляторов — с физикой, рендерингом и интерфейсом в одном флаконе.

А теперь — главный посыл: не бойтесь экспериментировать и не опускайте руки! В моделировании и компьютерной симуляции именно нестандартные подходы часто дают лучшие результаты. Возьмите привычный инструмент, попробуйте применить его в неожиданном контексте, соедините с вашими расчётами — и вы можете получить что‑то уникальное.

Дерзайте: каждый эксперимент приближает вас к собственному решению, которое однажды может перерасти в полноценный фреймворк или продукт. Даже если сначала кажется, что вы просто «изобретаете велосипед» — возможно, именно этот велосипед станет основой для чего‑то большего.