Как стать автором
Обновить
1065.87
OTUS
Цифровые навыки от ведущих экспертов

Реализуем собственную физику на основе Bullet в UE4. Часть 1

Время на прочтение10 мин
Количество просмотров3.9K
Автор оригинала: Steve

Недавно я столкнулся с нетривиальной задачей, которую мне необходимо было решить в работе над нашей следующей (пока еще не анонсированной) игрой. Мне нужна была детерминированная физика. То есть мне нужно было быть уверенным, что если взять четко определенное начальное состояние и прикладывать к нему одни и те же силы в рамках симуляции, результат будет всегда один и тот же.

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

UE использует PhysX (как и Unity), но то, каким образом он это делает, исключает поддержку детерминизма. Чтобы физический движок был детерминированным, абсолютно все должно быть прогнозируемым:

  • Симуляция должна продвигаться исключительно по фиксированным временным интервалам.

  • Все взаимодействия должны разрешаться в четко определенном, воспроизводимом порядке.

PhysX может быть детерминированным, если вы будете использовать фиксированные временные интервалы и включите режим “enhanced determinism”, который означает, что контакты разрешаются в неслучайном порядке. Вы можете включить этот режим в настройках UE, но вы не сможете реализовать симуляцию на фиксированных интервалах времени без внесения изменений в исходный код движка. Epic решила, что они будут запускать симуляцию с привязкой к частоте кадров, с возможностью выполнять промежуточные шаги (sub-step) при необходимости, но симуляция всегда будет синхронизирована с кадрами; это означает, что даже если вы реализуете промежуточный шаг с фиксированными временными интервалами, окончательный временной “отрезок” (slice) будет переменной величиной, так как он зависит от частоты кадров. Это исключает всякий детерминизм.

Я бы предпочел использовать этот временной отрезок на основе частоты кадров в качестве прогноза, а не как фактический шаг симуляции, но UE не дает нам такой возможности. Мы располагаем различными хуками к тикам физики в движке, но мы не сможем никак решить эту проблему без изменений исходного кода UE, чего я делать очень не хотел.

Я быстро пришел к выводу, что единственный способ сделать это чисто — это подключить собственную симуляцию физики. Хотя я мог бы создать еще одну параллельно запущенную сцену PhysX, я решил попробовать сделать это с помощью Bullet, исходя из следующих соображений:

  • Я мог быть на 100% уверен, что UE не будет вмешиваться в ее работу.

  • Bullet - хорошая встроенная система для интерполяции фиксированных тиков симуляции с переменной частотой кадров.

  • Bullet поддерживает трение скольжения и качения, что изначально меня очень заинтересовало (но в итоге не оправдало ожидания)

К счастью, поскольку UE дает вам доступ к внутренним компонентам C++, интеграция стороннего движка не слишком сложна, и эта история как раз об этом.

Сборка Bullet

Есть несколько нюансов касательно того, как вам следует собрать Bullet для совместимости с UE:

DLL-версия Microsoft C++ Runtime Library (CRT)

Premake-сборки Bullet по умолчанию используют статическую CRT, что вызовет конфликты, если вы попытаетесь связать их с модулем UE. Я отправил PR (уже замерженный), чтобы добавить такую возможность, однако позже выяснилось, что мне нужна была другая опция, которую Premake-версия не поддерживала. Поэтому я переключился на сборку CMake, которая уже поддерживает эту опцию.

Релизная версия Microsoft C++ Runtime Library (CRT) в дебаге

UE использует релизную версию CRT даже в дебажных сборках, поэтому, если вы привяжете дебажную версию библиотеки Bullet, которая не соответствует этому, он будет недоволен. Но вы можете указать модулю UE использовать вместо него дебажную CRT (опция bDebugBuildsActuallyUseDebugCRT = true в вашем файле build.cs), но на самом деле лучше все-таки использовать релизную версию CRT, потому что вам вряд ли будет нужно ее дебажить.

Поэтому я добавил это как опцию в сборку CMake для Bullet, чтобы все было в соответствии.

Инструкции сборки

  1. Запустите UI CMake;

  2. Неважно, что вы выберете для “Where to build the binaries”;

  3. Проверьте параметр USE_MSVC_RUNTIME_LIBRARY_DLL ;

  4. Проверьте параметр USE_MSVC_RELEASE_RUNTIME_ALWAYS ;

  5. Выберите расположение для LIBRARY_OUTPUT_PATH внутри вашего UE-проекта;

    • например UEProject/ThirdParty/lib.

  6. Нажмите кнопку Configure;

  7. Нажмите Generate;

  8. Нажмите Open Project;

  9. В VS выберите Build > Batch Build;

    • Проверьте App_HelloWorld в конфигурациях Debug, Release и RelWithDbgInfo;

    • Нажмите Build.

После этого у вас должны появиться статические библиотеки в LIBRARY_OUTPUT_PATH/Debug|Release|RelWithDebInfo.

Добавление в сборку вашего UE-проекта

Как и в любом C++ проекте, вам нужен доступ к заголовочным файлам и библиотекам. К сожалению, Bullet не разделяет свои заголовочные и cpp-файлы, поэтому проще всего будет убедиться, что у вас есть доступ к исходному коду. Лично у меня Bullet в качестве подмодуля внутри моего проекта, а мой параметр “where to build” в CMake установлен за пределами отслеживаемого каталога проекта для того, чтобы не было проблем с клинапом.

Затем вам нужно отредактировать Project.Build.cs, добавив папки заголовочного файла и библиотеки. Это должно выглядеть как-то так:

public class MyProject : ModuleRules
{
	public MyProject(ReadOnlyTargetRules Target) : base(Target)
	{
        // Эта часть вполне стандартная, но ваша может быть больше
		PCHUsage = PCHUsageMode.UseExplicitOrSharedPCHs;
		PublicDependencyModuleNames.AddRange(new string[] { "Core", "CoreUObject", "Engine", "InputCore" });
		PrivateDependencyModuleNames.AddRange(new string[] {  });
		AddBullet();
	}
    /// Помощник для предоставления нам ProjectRoot/ThirdParty
	private string ThirdPartyPath
    {
        get { return Path.GetFullPath( Path.Combine( ModuleDirectory, "../../ThirdParty/" ) ); }
    }
	protected AddBullet() 
	{
        // Тут все достаточно базово, только для одной платформы и конфигурации (Win64)
        // Если вы планируете большее количество вариантов сборки, то здесь вам придется проделать дополнительную работу 
        bool bDebug = Target.Configuration == UnrealTargetConfiguration.Debug || Target.Configuration == UnrealTargetConfiguration.DebugGame;
        bool bDevelopment = Target.Configuration == UnrealTargetConfiguration.Development;
        string BuildFolder = bDebug ? "Debug":
            bDevelopment ? "RelWithDebInfo" : "Release";
        string BuildSuffix = bDebug ? "_Debug":
            bDevelopment ? "_RelWithDebugInfo" : "";
        // Путь к библиотеке
        string LibrariesPath = Path.Combine(ThirdPartyPath, "lib", "bullet", BuildFolder);
        PublicAdditionalLibraries.Add(Path.Combine(LibrariesPath, "BulletCollision" + BuildSuffix + ".lib")); 
        PublicAdditionalLibraries.Add(Path.Combine(LibrariesPath, "BulletDynamics" + BuildSuffix + ".lib")); 
        PublicAdditionalLibraries.Add(Path.Combine(LibrariesPath, "LinearMath" + BuildSuffix + ".lib")); 
        // Путь для инклюда (здесь я просто использую исходники, так как у Bullet src и заголовки смешаны)
        PublicIncludePaths.Add( Path.Combine( ThirdPartyPath, "bullet3", "src" ) );
        PublicDefinitions.Add("WITH_BULLET_BINDING=1");
    }
}

 Включение Bullet в исходники UE

Еще один нюанс — есть несколько отличий от среды сборки UE, которые вызовут проблемы, если вы сделаете банальный #include Bullet в своем UE-коде.

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

// Этот необходим для подавления некоторых варнингов, которые передает UE4, а Bullet нет
THIRD_PARTY_INCLUDES_START
// Этот необходим для исправления проблем с выравниванием данных в памяти
PRAGMA_PUSH_PLATFORM_DEFAULT_PACKING
#include <btBulletDynamicsCommon.h>
PRAGMA_POP_PLATFORM_DEFAULT_PACKING
THIRD_PARTY_INCLUDES_END

Выравнивание данных в памяти (memory alignment) особенно коварно, потому что оно вроде как должно работать без макроса, но вы будете постоянно получать случайные ошибки памяти, вызванные тем, что Bullet и UE упорядочивают данные по-разному.

Лично я справился с этим, создав заголовки-обертки, которые делают все это за меня, чтобы мне не нужно было больше вспоминать об этом:

BulletMinimal.h (для включения в заголовки)

Этот заголовок включает в себя основные типы значений (векторы и т. д.) и заголовки для классов, от которых мы наследуем (см. далее), вместе с предварительными объявлениями для всего, на что мы ссылаемся указателем. Так что он идеально подходит для включения в наши заголовочные файлы UE, которые должны передавать данные Bullet.

#pragma once
#include "CoreMinimal.h"

// Самый минимальный инклюд для Bullet, который нам необходим в заголовках для типов значений/подклассов
// Этот макрос необходим для подавления некоторых варнингов, которые передает UE4, а Bullet нет
THIRD_PARTY_INCLUDES_START

// Этот макрос необходим для исправления проблем с выравниванием данных в памяти
PRAGMA_PUSH_PLATFORM_DEFAULT_PACKING

// Типы значений
#include <LinearMath/btQuaternion.h>
#include <LinearMath/btTransform.h>
#include <LinearMath/btVector3.h>

// Основные вещи, которые мы переопределяем
#include <LinearMath/btDefaultMotionState.h>
#include <LinearMath/btIDebugDraw.h>

// Предварительные объявления для всего остального, что мы используем
class btCollisionConfiguration;
class btCollisionDispatcher;
class btBroadphaseInterface;
class btConstraintSolver;
class btDynamicsWorld;
class btCollisionShape;
class btBoxShape;
class btCapsuleShape;
class btConvexHullShape;
class btCompoundShape;
class btSphereShape;
class btRigidBody;
class btCollisionObject;
PRAGMA_POP_PLATFORM_DEFAULT_PACKING
THIRD_PARTY_INCLUDES_END

BulletMain.h (для включения в исходники)

Этот заголовок — это то, что я включаю в исходные файлы, которым необходимо вызывать Bullet полностью. Это более короткий заголовок, но он (косвенно) включает больше всего.

#pragma once
#include "CoreMinimal.h"

// Более полное включение Bullet

// Этот макрос необходим для подавления некоторых варнингов, которые передает UE4, а Bullet нет
THIRD_PARTY_INCLUDES_START
// Этот макрос необходим для исправления проблем с выравниванием данных в памяти
PRAGMA_PUSH_PLATFORM_DEFAULT_PACKING
#include <btBulletDynamicsCommon.h>
#include <BulletCollision/CollisionShapes/btBoxShape.h>
PRAGMA_POP_PLATFORM_DEFAULT_PACKING
THIRD_PARTY_INCLUDES_END

Хорошо, мы закончили с подготовкой. Давайте же наконец приступим к использованию Bullet.

Принципы использования

Я не пытался полностью заменить коллизии и физику UE, и уж тем более не претендовал на упрощенную замену, которая должна работать для всего. Я всегда выступаю за простоту, поэтому я реализовал только то, что мне было нужно.

В моем случае я точно знаю заранее, какие объекты находятся в моей симуляции, и у меня есть актор (Actor), который “владеет” ими всеми. Поэтому я реализовал почти всю свою интеграцию физики в том единственном акторе, который контролировал жизненный цикл всего остального.

Этот актор также действовал в качестве локального центра симуляции, поэтому независимо от того, где он находился, симуляция Bullet выполнялась локально вокруг него, что помогало сохранить точность (благодаря этому мне нужна была лишь одинарная точность).

Важно отметить, что хотя у всех объектов UE отключена физика, у них все еще были коллайдеры PhysX, и я продолжал использовать коллизии UE для нефизических взаимодействий, таких как триггеры оверлапа. Это позволило мне ограничить область видимости только физической симуляцией.

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

Актор “Physics World”

Как упоминалось выше, почти вся реализация находится в одном акторе.

Хранение данных

Это почти все данные о мире Bullet, которые мы храним в одном месте. Опять же, в силу простоты моих требований (прямых) ссылок на Bullet в каких-либо других акторах нет. Все исключительно здесь, потому что этот актор владеет всеми объектами в этой части мира. Позже вы увидите, как все это согласуется.

...
#include "BulletMinimal.h"
...

class MYPROJECT_API APhysicsWorldActor : public AActor
{
    ...

	// Раздел Bullet
    // Глобальные объекты
	btCollisionConfiguration* BtCollisionConfig;
	btCollisionDispatcher* BtCollisionDispatcher;
	btBroadphaseInterface* BtBroadphase;
	btConstraintSolver* BtConstraintSolver;
	btDynamicsWorld* BtWorld;
    // Пользовательский интерфейс дебага
	btIDebugDraw* BtDebugDraw;
    // Динамические тела
	TArray<btRigidBody*> BtRigidBodies;
    // Static colliders// Статические коллайдеры
	TArray<btCollisionObject*> BtStaticObjects;
    // Повторно используемые формы коллизий
	TArray<btBoxShape*> BtBoxCollisionShapes;
	TArray<btSphereShape*> BtSphereCollisionShapes;
	TArray<btCapsuleShape*> BtCapsuleCollisionShapes;

	// Структура для хранения реюзабельных ConvexHull-форм на основе исходных BodySetup / subindex / scale
	struct ConvexHullShapeHolder
	{
		UBodySetup* BodySetup;
		int HullIndex;
		FVector Scale;
		btConvexHullShape* Shape;
	};

	TArray<ConvexHullShapeHolder> BtConvexHullCollisionShapes;
	// Эти формы предназначены для *потенциально* составных твердых фигур
	struct CachedDynamicShapeData
  
	{
		FName ClassName; // имя класса для кэша
		btCollisionShape* Shape;
		bool bIsCompound; // если true, это составная фигура, поэтому ее нужно удалять
		btScalar Mass;
		btVector3 Inertia; // потому что мы бы хотели предварительно вычислять это
	};
	TArray<CachedDynamicShapeData> CachedDynamicShapes;
    ...

};    

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

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

Инициализация мира

Прежде всего, давайте загрузим мир Bullet.

// Это довольно стандартная загрузка Bullet
	BtCollisionConfig = new btDefaultCollisionConfiguration();
	BtCollisionDispatcher = new btCollisionDispatcher (BtCollisionConfig);
	BtBroadphase = new btDbvtBroadphase ();
	BtConstraintSolver = new btSequentialImpulseConstraintSolver();
	BtWorld = new btDiscreteDynamicsWorld (BtCollisionDispatcher, BtBroadphase, BtConstraintSolver, BtCollisionConfig);
	// Я повозился с несколькими настройками в BtWorld->getSolverInfo(), но они специфичны для моих нужд
	// Вектор гравитации в наших единицах (1=1 см)
	BtWorld->setGravity(BulletHelpers::ToBtDir(FVector(0, 0, -980)));

Все это довольно безобидно. В нашем мире пока еще ничего нет, но все уже настроено и знает, в каком направлении действует гравитация.

Но погодите-ка, что это еще за BulletHelpers?

Это просто преобразователь единиц UE в единицы Bullet (и обратно). Bullet использует 1 = 1 м, тогда как UE использует 1 = 1 см. Чтобы добиться реалистичной физики, нам нужно использовать правильные единицы измерения. Как я упоминал ранее, я запускаю Bullet как локальную симуляцию относительно актора Physics World, поэтому я могу сохранить максимальную точность, и это актуально и для BulletHelpers. Я полагаю, что особого смысла заостряться на этомнет, потому что это простая математика, но я продемонстрирую ее в конце статьи.

На этом пока все. В следующей части мы поговорим о коллайдерах и твердых телах.


Приглашаем на открытое занятие «Особенности разработки MMO на Unreal Engine», на котором рассмотрим особенности и инструменты предоставляемые Unreal Engine при разработке MMO. Регистрация по ссылке.

Теги:
Хабы:
Всего голосов 8: ↑7 и ↓1+6
Комментарии2

Публикации

Информация

Сайт
otus.ru
Дата регистрации
Дата основания
Численность
101–200 человек
Местоположение
Россия
Представитель
OTUS