Переход на UNIGINE с Unreal Engine 4: гайд для программистов

от автора

Написание игровой логики, триггеры, ввод, рейкастинг и другое.

Специально для тех, кто ищет альтернативу Unreal Engine или Unity, мы продолжаем цикл статей про безболезненный переход на UNIGINE с зарубежных движков. В третьем выпуске рассмотрим миграцию с Unreal Engine 4 с точки зрения программиста.

Общая информация

Игровая логика в проекте на Unreal Engine 4 реализуется с помощью классов C++ или Blueprint Visual Scripting — встроенной системы визуального нодового программирования. Редактор Unreal Engine 4 позволяет создавать классы при помощи встроенного мастера классов (Class Wizard), выбрав нужный базовый тип.

В UNIGINE вы можете создавать проекты, используя C++ и C# API. При создании проекта просто выберите желаемое API и систему сборки:

В данной статье в основном затронем программирование на C++, т.к. полноценное программирование в Unreal Engine 4 возможно именно на этом языке.

Для C++ на выбор представлены готовые шаблоны проектов для следующих систем сборки:

  • Windows:

    • Visual Studio 2015+;

    • CMake;

    • Qt-based: Qt Creator, QMake или CMake (доступно для Engineering и Sim редакций SDK);

  • Linux:

    • GNU Make.

Далее просто выберите Open Code IDE, чтобы перейти к разработке логики в выбранной IDE для C++ проектов:

В Unreal Engine 4 достаточно унаследовать класс от базовых типов Game Framework, таких как AActor, APawn, ACharacter и т.п., чтобы переопределить их поведение в стандартных методах BeginPlay(), Tick() и EndPlay() и получить пользовательский actor.

Компонентный подход подразумевает, что логика реализуется в пользовательских компонентах, назначаемых на actor’ы — классах, унаследованных от UActorComponent и других компонентов, расширяющих стандартное поведение, определенное в методах InitializeComponent() и TickComponent().

В UNIGINE стандартный подход подразумевает, что логика приложения состоит из трех основных компонентов с разным циклом жизни:

  • Системная логика (исходный файл AppSystemLogic.cpp) существует в течение жизненного цикла приложения.

  • Логика мира (исходный файл AppWorldLogic.cpp) выполняется только когда мир загружен.

  • Логика редактора (исходный файл AppEditorLogic.cpp) выполняется только во время работы  пользовательского редактора.

У каждой логики есть стандартные методы, вызываемые в основном цикле движка. К примеру, можно использовать следующие методы логики мира:

  • init() — для инициализации ресурсов при загрузке мира;

  • update() — для обновления каждый кадр;

  • shutdown() — для уничтожения использованных ресурсов при закрытии мира;

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

Компонентный подход также доступен в UNIGINE при помощи встроенной компонентной системы. Логика компонента определяется в классе, производном от ComponentBase, на основе которого движок сгенерирует набор параметров компонента — Property, которые можно назначить любой ноде в редакторе. Каждый компонент также имеет набор методов, которые вызываются соответствующими функциями основного цикла движка.

Для примера создания простой игры с использованием компонентной системы, обратитесь к серии статей «Краткое руководство по программированию».

Сравним, как создаются простые компоненты в обоих движках. Заголовочный файл компонента в Unreal Engine 4 будет выглядеть примерно так:

UCLASS() class UMyComponent : public UActorComponent {     GENERATED_BODY()  public:     UPROPERTY(EditAnywhere)     int32 TotalDamage;       // Called after the owning Actor was created     void InitializeComponent();      // Called when the component or the owning Actor is being destroyed     void UninitializeComponent();      // Component version of Tick     void TickComponent(float DeltaTime, enum ELevelTick TickType, FActorComponentTickFunction* ThisTickFunction); };

И в UNIGINE. Компонентную систему сперва необходимо инициализировать в системной логике (AppSystemLogic.cpp):

/* .. */ #include <UnigineComponentSystem.h>  /* .. */  int AppSystemLogic::init() {     Unigine::ComponentSystem::get()->initialize();     return 1; }

И тогда можно написать новый компонент:

MyComponent.h:

#pragma once #include <Unigine.h> #include <UnigineComponentSystem.h>  using namespace Unigine; class MyComponent : public ComponentBase { public:     // объявление компонента MyComponent     COMPONENT(MyComponent, ComponentBase);      // объявление методов, вызываемых на определенных этапах цикла жизни компонента     COMPONENT_INIT(init);     COMPONENT_UPDATE(update);     COMPONENT_SHUTDOWN(shutdown);      // объявление параметра компонента, который будет доступен в редакторе     PROP_PARAM(Float, speed, 30.0f);      // определение имени Property, которое будет сгенерировано и ассоциировано с компонентом     PROP_NAME("my_component"); protected:     void init();     void update();     void shutdown(); };

MyComponent.cpp:

#include "MyComponent.h"  // регистрация компонента MyComponent REGISTER_COMPONENT(MyComponent);  // вызов будет произведен при инициализации компонента void MyComponent::init(){}  // будет вызван каждый кадр void MyComponent::update(){}  // будет вызван при уничтожении компонента или ноды, которой он назначен void MyComponent::shutdown(){}

Теперь необходимо сгенерировать property для нашего компонента. Для этого:

  1. Соберите приложение с помощью IDE.

  2. Запустите приложение один раз, чтобы получить property компонента, сгенерированное движком.

  3. Перейдите в редактор и назначьте сгенерированное property ноде.

  4. Наконец, работу логики компонента можно проверить, запустив приложение.

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

Немного про API

Все объекты в Unreal Engine 4 наследуются от UObject, доступ к ним возможен при помощи стандартных C++ указателей или умных указателей Unreal Smart Pointer Library.

В UNIGINE API есть система умных указателей, управляющих существованием нод и других объектов в памяти:

// создать ноду типа NodeType <NodeType>Ptr nodename = <NodeType>::create(<construction_parameters>); // удалить ноду из мира nodename.deleteLater();

К примеру, вот как выглядит создание меша из ассета, редактирование, присвоение новой ноде типа ObjectMeshStatic и удаление:

    MeshPtr mesh = Mesh::create();     mesh->load("fbx/model.fbx/model.mesh");     mesh->addBoxSurface("box_surface", Math::vec3(0.5f, 0.5f, 0.5f));     ObjectMeshStaticPtr my_object = ObjectMeshStatic::create(mesh);     my_object.deleteLater();     mesh.clear();

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

MyComponent *my_component = getComponent<MyComponent>(node);

Типы данных

Тип данных

Unreal Engine 4

UNIGINE

Числовые типы

int8/uint8

int16/uint16

int32/uint32

int64/uint64,

float,

double

Стандартные типы C++:

signed и unsigned char, short, int, long, long long, float, double

Строки

FString:

FString MyStr = TEXT(«Hello, Unreal 4!»).

String:

String str(«Hello, UNIGINE 2!»);

Контейнеры

TArray, TMap, TSet

Vector, Map, Set и другие:

Vector<NodePtr> nodes;

World::getNodes(nodes);

for(NodePtr n : nodes) { }

Векторы и матрицы

FVector3f — FVector3d,

FMatrix44f — FMatrtix44d и другие

vec3 — dvec3,

mat4 — dmat4 и другие типы в математической библиотеке.

UNIGINE поддерживает как одинарную точность (Float), так и двойную точность координат (Double), доступную в зависимости от редакции SDK. Почитайте про использование универсальных типов данных, подходящих под любой проект.

Основные примеры кода

Вывод в консоль

Unreal Engine 4

UNIGINE

UE_LOG(LogTemp, Warning, TEXT("Your message"));

Log::message("Debug info: %s\n", text);

Log::message("Debug info: %d\n", number);

См. также:

  • Дополнительные типы сообщений в API класса Log.

Загрузка сцены

Unreal Engine 4

UNIGINE

UGameplayStatics::OpenLevel(GetWorld(), TEXT("MyLevelName"));

World::loadWorld("YourWorldName");   

Доступ к Actor / Node из компонента

Unreal Engine 4

UNIGINE

MyComponent->GetOwner();

NodePtr owning_node = node;

См. также:

  • Видеоруководство, демонстрирующее, как получить доступ к нодам из компонентов с помощью C++ Component System.

Доступ к компоненту из Actor / Node

Unreal Engine 4:

UMyComponent* MyComp = MyActor->FindComponentByClass<UMyComponent>();

UNIGINE:

MyComponent *my_component = getComponent<MyComponent>(node);

Работа с направлениями

В Unreal Engine 4 компонент USceneComponent (или производный) отвечает за действия с трансформацией actor’а. Чтобы получить вектор направления по одной из осей с учетом ориентации в мировых координатах, можно использовать соответствующие методы USceneComponent (GetForwardVector()) или AActor (GetActorForwardVector()).

В UNIGINE трансформация ноды в пространстве представлена ее матрицей трансформации (mat4), а все основные операции с трансформацией или иерархией нод доступны при помощи методов класса Node. Такой же вектор направления в UNIGINE получается с помощью метода Node::getWorldDirection():

Unreal Engine 4

UNIGINE

FVector forward = MyActor->GetActorForwardVector();

FVector up = MyActor->GetActorUpVector();

FVector right = MyActor->GetActorRightVector();

FVector CurrentLocation = MyActor->GetActorLocation();

CurrentLocation += forward * speed * DeltaTime;

MyActor->SetActorLocation(CurrentLocation);

mat4 t_local = node->getTransform();

mat4 t_world = node->getWorldTransform();

vec3 pos_world = node->getWorldPosition();

vec3 forward = node->getWorldDirection(Math::AXIS_Y);

vec3 right = node->getWorldDirection(Math::AXIS_X);

vec3 up = node->getWorldDirection(Math::AXIS_Z);

node->translate(forward * speed * Game::getIFps());

См. также:

Более плавный игровой процесс с DeltaTime / IFps

В Unreal Engine 4, чтобы гарантировать, что определенные действия выполняются за одно и то же время независимо от частоты кадров (например, изменение положения один раз в секунду и т. д.), используется множитель deltaTime (время в секундах, которое потребовалось для завершения последнего кадра), передаваемый методу Tick(float deltaTime). То же самое в UNIGINE называется Game::getIFps():

Unreal Engine 4

UNIGINE

void AMyActor::Tick(float deltaTime)

{

    Super::Tick(deltaTime);

    /* .. */

}

node->rotate(0, 0, speed * Game::getIFps());

Рисование отладочных данных

Unreal Engine 4:

DrawDebugLine(GetWorld(), traceStart, traceEnd, FColor::Green, true, 1.0f);

В UNIGINE за вспомогательную отрисовку отвечает синглтон Visualizer:

// включаем вспомогательную визуализацию Visualizer::setEnabled(true);  /*..*/  Visualizer::renderLine3D(vec3_zero, vec3(5, 0, 0), vec4_one); Visualizer::renderVector(node->getPosition(), node->getDirection(Math::AXIS_Y) * 10, vec4(1, 0, 0, 1));

Примечание. Visualizer также можно включить с помощью консольной команды show_visualizer 1.

См. также:

  • Все типы визуализаций в API класса Visualizer.

Поиск Actor / Node

Unreal Engine 4:

// поиск Actor или UObject по имени AActor* MyActor = FindObject<AActor>(nullptr, TEXT("MyNamedActor"));  // Поиск Actor по типу for (TActorIterator<AMyActor> It(GetWorld()); It; ++It) {     AMyActor* MyActor = *It;     // ... }

UNIGINE:

// поиск Node по имени NodePtr my_node = World::getNodeByName("my_node");  // поиск всех нод с данным именем Vector<NodePtr> nodes; World::getNodesByName("test", nodes);  // получение прямого потомка ноды int index = node->findChild("child_node"); NodePtr direct_child = node->getChild(index);  // Рекурсивный поиск ноды по имени среди всех потомков в иерархии NodePtr child = node->findNode("child_node", 1);

Приведение от типа к типу

Классы всех типов нод являются производными от Node в UNIGINE, поэтому чтобы получить доступ к функциональности ноды определенного типа (например, ObjectMeshStatic), необходимо провести понижающее приведение типа — Downcasting (приведение от базового типа к производному), которое выполняется с использованием специальных конструкций. Чтобы выполнить Upcasting (приведение от производного типа к базовому), можно как обычно просто использовать сам экземпляр:

Unreal Engine 4

UNIGINE

UPrimitiveComponent* Primitive = MyActor->GetComponentByClass(UPrimitiveComponent::StaticClass());

USphereComponent* SphereCollider = Cast<USphereComponent>(Primitive);

if (SphereCollider != nullptr)

{

// ...

}

// поиск ноды в мире по имени

NodePtr baseptr = World::getNodeByName("my_meshdynamic");

// приведение к производному типу с автоматической проверкой типа

ObjectMeshDynamicPtr derivedptr = checked_ptr_cast<ObjectMeshDynamic>(baseptr);

// статическое приведение

ObjectMeshDynamicPtr derivedptr = static_ptr_cast<ObjectMeshDynamic>(World::getNodeByName("my_meshdynamic"));

// приведение к Object — базовому типу для ObjectMeshDynamic

ObjectPtr object = derivedptr;

// приведение к Node — базовому типу для всех объектов мира

NodePtr node = derivedptr;

Уничтожение Actor / Node

Unreal Engine 4

UNIGINE

MyActor->Destroy();

// уничтожение actor’а с 1-секундной задержкой

MyActor->SetLifeSpan(1);

node.deleteLater(); // рекомендуемый способ уничтожить ноду

//вызов будет произведен между кадрами

node.deleteForce(); // форсированное удаление, может быть небезопасным

Для выполнения отложенного удаления ноды в UNIGINE можно создать компонент, который будет отвечать за таймер и удаление.

Создание экземпляра Actor / Node Reference

За создание нового экземпляра actor (Spawning) отвечает метод UWorld::SpawnActor():

AKAsset* SpawnedActor1 = (AKAsset*) GetWorld()->SpawnActor(AKAsset::StaticClass(), NAME_None, &Location);

В Unreal Engine 4 клонировать существующий actor можно следующим образом:

AMyActor* CreateCloneOfMyActor(AMyActor* ExistingActor, FVector SpawnLocation, FRotator SpawnRotation) { UWorld* World = ExistingActor->GetWorld(); FActorSpawnParameters SpawnParams; SpawnParams.Template = ExistingActor; World->SpawnActor<AMyActor>(ExistingActor->GetClass(), SpawnLocation, SpawnRotation, SpawnParams); }

В UNIGINE используйте Node::clone() для клонирования ноды, существующей в мире, и World::loadNode для загрузки иерархии нод из ассета .node. В этом случае на сцену будет добавлена ​​вся иерархия нод, которая была сохранена как Node Reference. Вы можете обратиться к ассету либо через параметр компонента, либо вручную, указав виртуальный путь к нему:

// MyComponent.h PROP_PARAM(File, node_to_spawn);  // MyComponent.cpp /* .. */ void MyComponent::init() { // создание новой ноды Dummy NodeDummyPtr dummy = NodeDummy::create();  // клонирование существующей ноды NodePtr cloned = dummy->clone();  // загрузка иерархии нод из ассета NodePtr spawned = World::loadNode(node_to_spawn.get()); spawned->setWorldPosition(node->getWorldPosition());  // загрузка с указанием пути в файловой системе NodePtr spawned_manually = World::loadNode("nodes/node_reference.node"); }

Для параметра компонента также необходимо указать ассет .node в редакторе:

 

Еще один способ загрузить содержимое ассета *.node — создать NodeReference и работать с иерархией нод как с одним объектом. Тип Node Reference имеет ряд внутренних оптимизаций и тонких моментов (кэширование нод, распаковка иерархии и т.д.), поэтому важно учитывать специфику работы с этими объектами.

void MyComponent::update() { NodeReferencePtr nodeRef = NodeReference::create("nodes/node_reference_0.node"); }

Запуск скриптов в редакторе

Unreal Engine 4 позволяет расширять функциональность редактора с помощью Blueprint/Python скриптов.

UNIGINE не поддерживает выполнение логики приложения на C++ внутри редактора. Основной способ расширить функциональность редактора — плагины, написанные на C++.

Для быстрого тестирования или автоматизации разработки можно написать логику на UnigineScript. UnigineScript API обладает только базовой функциональностью и ограниченной сферой применения, но доступен для любого проекта на UNIGINE, включая проекты на C++.

Есть два способа добавить скриптовую логику в проект:

  • Создав скрипт мира:

  1. Создайте ассет скрипта .usc.

  1. Определите в нем логику. При необходимости добавьте проверку, загружен ли редактор:

//Исходный код (UnigineScript) #include <core/unigine.h> vec3 lookAtPoint = vec3_zero; Node node; int init() {     node = engine.world.getNodeByName("material_ball");     return 1; } int update() { if(engine.editor.isLoaded())         node.worldLookAt(lookAtPoint);     return 1; }
  1. Выделите текущий мир и укажите для него сценарий мира. Нажмите Apply и перезагрузите мир.

  1. Проверьте окно консоли на наличие ошибок.

После этого логика скрипта будет выполняться как в редакторе, так и в приложении.

  • Используя WorldExpression. С той же целью можно использовать ноду WorldExpression, выполняющую логику при добавлении в мир:

  1. Нажмите Create -> Logic -> Expression и поместите новую ноду WorldExpression в мир.

  2. Напишите логику на UnigineScript в поле Source:

//Исходный код (UnigineScript) { vec3 lookAtPoint = vec3_zero; Node node = engine.world.getNodeByName("my_node"); node.worldLookAt(lookAtPoint); }
  1. Проверьте окно Console на наличие ошибок.

  2. Логика будет выполнена немедленно.

Триггеры

Unreal Engine 4:

UCLASS() class AMyActor : public AActor { GENERATED_BODY()  // компонент триггера UPROPERTY() UPrimitiveComponent* Trigger;  AMyActor() {     Trigger = CreateDefaultSubobject<USphereComponent>(TEXT("TriggerCollider"));      Trigger.bGenerateOverlapEvents = true;     Trigger.SetCollisionEnabled(ECollisionEnabled::QueryOnly); }  virtual void NotifyActorBeginOverlap(AActor* Other) override;  virtual void NotifyActorEndOverlap(AActor* Other) override; };

В UNIGINE Trigger — это специальный тип нод, вызывающих события в определенных ситуациях:

Важно! PhysicalTrigger не обрабатывает события столкновения, для этого тела и сочленения предоставляют свои собственные события.

WorldTriger — наиболее распространенный тип триггера, который можно использовать в игровой логике:

WorldTriggerPtr trigger; int enter_callback_id;                  // коллбэк при попадании внутрь объема триггера void AppWorldLogic::enter_callback(NodePtr node){     Log::message("\nA node named %s has entered the trigger\n", node->getName()); }                  // implement the leave callback void AppWorldLogic::leave_callback(NodePtr node){     Log::message("\nA node named %s has left the trigger\n", node->getName()); }                  int AppWorldLogic::init() {     // создание WorldTrigger ноды     trigger = WorldTrigger::create(Math::vec3(3.0f));                      // подписка на событие попадания ноды внутрь объема триггера     // и сохранение id коллбэка для будущего удаления     enter_callback_id = trigger->addEnterCallback(MakeCallback(this, &AppWorldLogic::enter_callback));     // подписка на событие покидания нодой объема триггера     trigger->addLeaveCallback(MakeCallback(this, &AppWorldLogic::leave_callback));      return 1; }

Обработка ввода

Unreal Engine 4:

UCLASS() class AMyPlayerController : public APlayerController {     GENERATED_BODY()      void SetupInputComponent()     {         Super::SetupInputComponent();          InputComponent->BindAction("Fire", IE_Pressed, this, &AMyPlayerController::HandleFireInputEvent);         InputComponent->BindAxis("Horizontal", this, &AMyPlayerController::HandleHorizontalAxisInputEvent);         InputComponent->BindAxis("Vertical", this, &AMyPlayerController::HandleVerticalAxisInputEvent);     }      void HandleFireInputEvent();     void HandleHorizontalAxisInputEvent(float Value);     void HandleVerticalAxisInputEvent(float Value); };

UNIGINE:

/* .. */  #include <UnigineApp.h> #include <UnigineConsole.h> #include <UnigineInput.h>  /* .. */  void MyInputController::update()  {     // при нажатии правой кнопки мыши     if (Input::isMouseButtonDown(Input::MOUSE_BUTTON_RIGHT))     {         Math::ivec2 mouse = Input::getMouseCoord();         // сообщить координаты курсора мыши в консоль         Log::message("Right mouse button was clicked at (%d, %d)\n", mouse.x, mouse.y);     }          // закрыть приложение при нажатии клавиши 'Q' с учетом того, открыта ли консоль     if (Input::isKeyDown(Input::KEY_Q) && !Console::isActive())     {         App::exit();     } }  /* .. */

Также можно использовать синглтон ControlsApp для обработки привязок элементов управления к набору предустановленных состояний ввода. Чтобы настроить привязки, откройте настройки Controls в редакторе:

#include <Unigine.h>  /* .. */  void MyInputController::init()  {     // переназначение состояний клавишам и кнопкам вручную     ControlsApp::setStateKey(Controls::STATE_FORWARD, App::KEY_PGUP);     ControlsApp::setStateKey(Controls::STATE_BACKWARD, App::KEY_PGDOWN);     ControlsApp::setStateKey(Controls::STATE_MOVE_LEFT, 'l');     ControlsApp::setStateKey(Controls::STATE_MOVE_RIGHT, 'r');     ControlsApp::setStateButton(Controls::STATE_JUMP, App::BUTTON_LEFT); }  void MyInputController::update()  {     if (ControlsApp::clearState(Controls::STATE_FORWARD))     {         Log::message("FORWARD key pressed\n");     }     else if (ControlsApp::clearState(Controls::STATE_BACKWARD))     {         Log::message("BACKWARD key pressed\n");     }     else if (ControlsApp::clearState(Controls::STATE_MOVE_LEFT))     {         Log::message("MOVE_LEFT key pressed\n");     }     else if (ControlsApp::clearState(Controls::STATE_MOVE_RIGHT))     {         Log::message("MOVE_RIGHT key pressed\n");     }     else if (ControlsApp::clearState(Controls::STATE_JUMP))     {         Log::message("JUMP button pressed\n");     } }  /* .. */

Проверка пересечения луча с геометрией (Raycast) 

Unreal Engine 4:

APawn* AMyPlayerController::FindPawnCameraIsLookingAt() { FCollisionQueryParams Params; Params.AddIgnoredActor(GetPawn());  FHitResult Hit;  FVector Start = PlayerCameraManager->GetCameraLocation(); FVector End = Start + (PlayerCameraManager->GetCameraRotation().Vector() * 1000.0f); bool bHit = GetWorld()->LineTraceSingle(Hit, Start, End, ECC_Pawn, Params);  if (bHit) {     return Cast<APawn>(Hit.Actor.Get()); }  return nullptr; }

В UNIGINE то же самое достигается с помощью Intersections:

#include "MyComponent.h" #include <UnigineWorld.h> #include <UnigineVisualizer.h> #include <UnigineGame.h> #include <UnigineInput.h>  using namespace Unigine; using namespace Math;  REGISTER_COMPONENT(MyComponent);  void MyComponent::init() {     Visualizer::setEnabled(true); }  void MyComponent::update() {     // получим координаты начальной и конечной точек луча     ivec2 mouse = Input::getMouseCoord();     float length = 100.0f;     vec3 start = Game::getPlayer()->getWorldPosition();     vec3 end = start + vec3(Game::getPlayer()->getDirectionFromScreen(mouse.x, mouse.y)) * length;      // игнорируем поверхности мешей с включенными битами маски Intersection     int mask = ~(1 << 2 | 1 << 4);      WorldIntersectionNormalPtr intersection = WorldIntersectionNormal::create();      ObjectPtr obj = World::getIntersection(start, end, mask, intersection);      if (obj)     {         vec3 point = intersection->getPoint();         vec3 normal = intersection->getNormal();         Visualizer::renderVector(point, point + normal, vec4_one);         Log::message("Hit %s at (%f,%f,%f)\n", obj->getName(), point.x, point.y, point.z);     } }

Напоминаем, что получить доступ к бесплатной версии UNIGINE 2 Community можно, заполнив форму на нашем сайте.

Все комплектации UNIGINE:

  • Community — базовая версия для любителей и независимых разработчиков. Достаточна для разработки видеоигр большинства популярных жанров (включая VR).

  • Engineering — расширенная, специализированная версия. Включает множество заготовок для инженерных задач.

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

Подробнее о комплектациях и ценах


ссылка на оригинал статьи https://habr.com/ru/company/unigine/blog/670518/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *