Современные игры становятся все реалистичнее, и один из способов этого добиться — создать разрушаемое окружение. К тому же, крушить мебель, растения, стены, здания и целые города просто весело.
Наиболее яркими примерами игр с хорошей разрушаемостью можно назвать Red Fraction: Guerrilla с ее возможностью пробивать тоннель сквозь Марс, Battlefield: Bad Company 2, где при желании можно превратить весь сервер в пепелище, и Control с его процедурным разрушением всего, что попадается на глаза.
В 2019 году Epic Games представила демо новой высокопроизводительной системы физики и разрушений Chaos движка Unreal. Новая система позволяет создавать разрушения разного масштаба, имеет поддержку редактора эффектов Niagara и при этом отличается экономным расходованием ресурсов.
А пока Chaos находится на стадии бета-тестирования, поговорим об альтернативных подходах к созданию разрушаемых объектов в Unreal Engine 4. В этой статье один из них опишем подробно.
Требования
Начнем с перечисления того, чего мы хотели бы достичь:
- Художественный контроль. Мы хотим, чтобы наши художники могли создавать разрушаемые объекты, как им угодно.
- Разрушения, не влияющие на геймплей. Они должны быть чисто визуальными, не нарушать ничего, что связано с игровым процессом.
- Оптимизация. Мы хотим иметь полный контроль над производительностью и не допустить снижения производительности ЦП.
- Простота установки. Настройка конфигурации таких объектов должна быть понятна художникам, поэтому необходимо, чтобы она включала только необходимый минимум шагов.
За референс в этой статье было взято разрушаемое окружение из Dark Souls 3 и Bloodborne.
Основная идея
На самом деле, идея проста:
- Создаем видимую базовую сетку;
- Добавляем скрытые части сетки;
- При разрушении: скрываем базовую сетку -> показываем ее части -> запускаем физику.
Подготовка ассетов
Для подготовки объектов будем использовать Blender. Для создания сетки, по которой они будут разрушаться, используем аддон Blender под названием Cell Fracture.
Включение аддона
Сначала нам понадобится включить аддон, поскольку по умолчанию он выключен.
Включение аддона Cell Fracture
Поиск аддона (F3)
Затем включаем аддон на выбранной сетке.
Настройки конфигурации
Запуск аддона
Посмотрите видео, сверьтесь с настройками оттуда. Убедитесь, что вы правильно настроили свои материалы.
Выбор материала для разворачивания разрезанных частей
Затем создадим UV-карту для этих частей.
Добавляем разделение границ (Edge Split)
Edge Split исправит затенение.
Модификаторы ссылок
Их использование позволит применить Edge Split ко всем выбранным частям.
Завершение
Вот как это выглядит в Blender. По сути, нам не нужно моделировать все части по отдельности.
Реализация
Базовый класс
Наш разрушаемый объект — это Актер, у которого есть несколько компонентов:
- Корневая сцена;
- Static Mesh — базовая сетка;
- Бокс для столкновений;
- Бокс для перекрытий;
- Радиальная сила.
Изменим кое-какие настройки в конструкторе:
- Отключим функцию таймера Tick (вообще никогда не забывайте отключать ее для актеров, которым она не нужна);
- Устанавливаем статичную мобильность для всех компонентов;
- Отключаем влияние на навигацию;
- Настраиваем профили столкновений.
Настройка актера в конструкторе
ADestroyable::ADestroyable()
{
PrimaryActorTick.bCanEverTick = false; //отключаем Tick
bDestroyed = false;
RootScene = CreateDefaultSubobject<USceneComponent>(TEXT("RootComp")); // корневая сцена, где все содержится
RootScene->SetMobility(EComponentMobility::Static);
RootComponent = RootScene;
Mesh = CreateDefaultSubobject<UStaticMeshComponent>(TEXT("BaseMeshComp")); // базовая сетка
Mesh->SetMobility(EComponentMobility::Static);
Mesh->SetupAttachment(RootScene);
Collision = CreateDefaultSubobject<UBoxComponent>(TEXT("CollisionComp")); // столкновения, зависящие от пересечений объектов
Collision->SetMobility(EComponentMobility::Static);
Collision->SetupAttachment(Mesh);
OverlapWithNearDestroyable = CreateDefaultSubobject<UBoxComponent>(TEXT("OverlapWithNearDestroyableComp")); // столкновения, ищущие разрушаемые объекты поблизости
OverlapWithNearDestroyable->SetMobility(EComponentMobility::Static);
OverlapWithNearDestroyable->SetupAttachment(Mesh);
Force = CreateDefaultSubobject<URadialForceComponent>(TEXT("RadialForceComp")); // составляющая силы для добавления импульса при разрушении
Force->SetMobility(EComponentMobility::Static);
Force->SetupAttachment(RootScene);
Force->Radius = 100.f;
Force->bImpulseVelChange = true;
Force->AddCollisionChannelToAffect(ECC_WorldDynamic);
/* установка столкновений */
Mesh->SetCollisionObjectType(ECC_WorldDynamic);
Mesh->SetCollisionEnabled(ECollisionEnabled::QueryAndPhysics);
Mesh->SetCollisionResponseToAllChannels(ECR_Block);
Mesh->SetCollisionResponseToChannel(ECC_Visibility, ECR_Ignore);
Mesh->SetCollisionResponseToChannel(ECC_Camera, ECR_Ignore);
Mesh->SetCollisionResponseToChannel(ECC_CameraFadeOverlap, ECR_Overlap);
Mesh->SetCollisionResponseToChannel(ECC_Interaction, ECR_Ignore);
Mesh->SetCanEverAffectNavigation(false);
Collision->SetBoxExtent(FVector(50.f, 50.f, 50.f));
Collision->SetCollisionObjectType(ECC_WorldDynamic);
Collision->SetCollisionEnabled(ECollisionEnabled::QueryOnly);
Collision->SetCollisionResponseToAllChannels(ECR_Ignore);
Collision->SetCollisionResponseToChannel(ECC_Melee, ECR_Overlap);
Collision->SetCollisionResponseToChannel(ECC_Pawn, ECR_Overlap);
Collision->SetCollisionResponseToChannel(ECC_Projectile, ECR_Overlap);
Collision->SetCanEverAffectNavigation(false);
Collision->OnComponentBeginOverlap.AddDynamic(this, &ADestroyable::OnBeginOverlap);
Collision->OnComponentEndOverlap.AddDynamic(this, &ADestroyable::OnEndOverlap);
OverlapWithNearDestroyable->SetBoxExtent(FVector(40.f, 40.f, 40.f));
OverlapWithNearDestroyable->SetCollisionObjectType(ECC_WorldDynamic);
OverlapWithNearDestroyable->SetCollisionEnabled(ECollisionEnabled::NoCollision); // отключение столкновений, включающихся на один кадр в начале разрушения
OverlapWithNearDestroyable->SetCollisionResponseToAllChannels(ECR_Ignore);
OverlapWithNearDestroyable->SetCollisionResponseToChannel(ECC_WorldDynamic, ECR_Overlap);
OverlapWithNearDestroyable->CanCharacterStepUp(false);
OverlapWithNearDestroyable->SetCanEverAffectNavigation(false);
}
В Begin Play мы собираем некоторые данные и настраиваем их:
- Ищем все части с тэгом «dest»;
- Настраиваем столкновения для всех частей, чтобы художнику не нужно было думать об этом;
- Устанавливаем статичную мобильность;
- Скрываем все части.
Настройка частей объекта в Begin Play
void ADestroyable::ConfigureBreakablesOnStart()
{
Mesh->SetCullDistance(BaseMeshMaxDrawDistance); // кастомная отрисовка расстояний для нашей базовой сетки
for (UStaticMeshComponent* Comp : GetBreakableComponents()) // выбираем все части
{
Comp->SetCollisionEnabled(ECollisionEnabled::NoCollision); // отключаем столкновения
Comp->SetCollisionResponseToAllChannels(ECR_Ignore); // отключаем все
Comp->SetCollisionResponseToChannel(ECC_WorldStatic, ECR_Block);
Comp->SetMobility(EComponentMobility::Static); // не забываем делать статичным то, что не движется
Comp->SetHiddenInGame(true); // скрываем части перед разрушением, до него у нас должна отображаться базовая сетка
}
}
Простая функция для получения компонентов частей
TArray<UStaticMeshComponent*> ADestroyable::GetBreakableComponents()
{
if (BreakableComponents.Num() == 0) // есть ли у нас что-то в кэше?
{
TInlineComponentArray<UStaticMeshComponent*> ComponentsByClass; //сохраняем все статичные компоненты сетки
GetComponents(ComponentsByClass);
TArray<UStaticMeshComponent*> ComponentsByTag; // храним все части с тэгом «dest»
ComponentsByTag.Reserve(ComponentsByClass.Num());
for (UStaticMeshComponent* Component : ComponentsByClass)
{
if (Component->ComponentHasTag(TEXT("dest")))
{
ComponentsByTag.Push(Component);
}
}
BreakableComponents = ComponentsByTag; // храним данные для дальнейшего использования
}
return BreakableComponents;
}
Триггеры разрушения
Существует три способа спровоцировать разрушение.
OnOverlap
Разрушение происходит в том случае, когда кто-то бросает или каким-либо другим образом использует предмет, активирующий процесс, — например, катящийся мяч.
OnTakeDamage
Разрушаемый объект получает урон.
OnOverlapWithNearDestroyable
В этом случае один разрушаемый объект накладывается на другой. В нашем случае для простоты они оба ломаются.
Флоу разрушения объекта
Диаграмма разрушения объекта
Показ разрушаемых частей
void ADestroyable::ShowBreakables(FVector DealerLocation, bool ByOtherDestroyable /*= false*/)
{
float ImpulseStrength = ByOtherDestroyable ? -500.f : -1000.f; // установка мощности импульса
FVector Impulse = (DealerLocation - GetActorLocation()).GetSafeNormal() * ImpulseStrength; // установка вектора импульса в зависимости от местоположения объекта, от которого он исходит
for (UStaticMeshComponent* Comp : GetBreakableComponents()) // собираем все части
{
Comp->SetMobility(EComponentMobility::Movable); //включение физики
FBodyInstance* RootBI = Comp->GetBodyInstance(NAME_None, false);
if (RootBI)
{
RootBI->bGenerateWakeEvents = true; // физика активируется на частях объекта
if (PartsGenerateHitEvent)
{
RootBI->bNotifyRigidBodyCollision = true; // активация события OnComponentHit
Comp->OnComponentHit.AddDynamic(this, &ADestroyable::OnPartHitCallback); // привязка к компоненту для создания на нем эффектов
}
}
Comp->SetHiddenInGame(false); // показ частей объекта
Comp->SetCollisionEnabled(ECollisionEnabled::QueryAndPhysics); // включение столкновений
Comp->SetSimulatePhysics(true); // включение физики
Comp->AddImpulse(Impulse, NAME_None, true); // активация импульса
if (ByOtherDestroyable)
Comp->AddAngularImpulseInRadians(Impulse * 5.f); //если разрушение ближайшего объекта вносит вклад в импульс, то учитываем его
//остановка расстояния для прорисовки частей
Comp->SetCullDistance(PartsMaxDrawDistance);
Comp->OnComponentSleep.AddDynamic(this, &ADestroyable::OnPartPutToSleep); // отключаем физику и делаем мобильность статичной
}
}
Главная функция разрушения
void ADestroyable::Break(AActor* InBreakingActor, bool ByOtherDestroyable /*= false*/)
{
if (bDestroyed) // объект уже разрушен, ничего больше делать не надо
return;
bDestroyed = true;
Mesh->SetHiddenInGame(true); // скрыть базовую сетку
Mesh->SetCollisionEnabled(ECollisionEnabled::NoCollision); // отключить столкновения с базовой сеткой
Collision->SetCollisionEnabled(ECollisionEnabled::NoCollision); // отключить проверки столкновений по перекрытию
OverlapWithNearDestroyable->SetCollisionEnabled(ECollisionEnabled::NoCollision);
ShowBreakables(InBreakingActor->GetActorLocation(), ByOtherDestroyable); // show parts
Force->bImpulseVelChange = !ByOtherDestroyable; // отладка компонента силы, если она сбилась другим разрушением
Force->FireImpulse(); // активация радиальной силы
/* проверка других разрушаемых объектов */
OverlapWithNearDestroyable->SetCollisionEnabled(ECollisionEnabled::QueryOnly); // включение столкновений для проверки близлежащих объектов
TArray<AActor*> OtherOverlapingDestroyables;
OverlapWithNearDestroyable->GetOverlappingActors(OtherOverlapingDestroyables, ADestroyable::StaticClass()); // получение других объектов в боксе
for (AActor* OtherActor : OtherOverlapingDestroyables)
{
if (OtherActor == this)
continue;
if (ADestroyable* OtherDest = Cast<ADestroyable>(OtherActor))
{
if (OtherDest->IsDestroyed()) // проверка, не разрушен ли объект
continue;
OtherDest->Break(this, true); // разрушение близлежащего объекта
}
}
OverlapWithNearDestroyable->SetCollisionEnabled(ECollisionEnabled::NoCollision); // отключение столкновений
GetWorld()->GetTimerManager().SetTimer(ForceSleepTimerHandle, this, &ADestroyable::ForceSleep, FORCESLEEPDELAY, false); // переход в состояние сна, если в физике не случилось никаких событий
if(bDestroyAfterDelay)
GetWorld()->GetTimerManager().SetTimer(DestroyAfterBreakTimerHandle, this, &ADestroyable::DestroyAfterBreaking, DESTROYACTORDELAY, false); // установка таймера для проверки, можно ли уничтожить всего актера
OnBreakBP(InBreakingActor, ByOtherDestroyable); // blueprint отвечает за аудиовизуальные эффекты
}
Что делать с функцией сна
Когда срабатывает функция Sleep, мы отключаем физику/столкновения и устанавливаем статичную мобильность. Благодаря этому производительность увеличится.
Каждый примитивный компонент с физикой может перейти в режим сна. Привязываемся к этой функции при разрушении.
Эта функция может быть присуща любому примитиву. Мы привязываемся к ней для завершения действия над объектом.
Иногда физический объект не переходит в режим сна и продолжает обновляться, даже если вы не видите при этом никакого движения. Если он продолжает моделировать физику, мы заставляем все его части перейти в режим сна спустя 15 секунд.
Функция принудительного сна, вызываемая таймером
void ADestroyable::OnPartPutToSleep(UPrimitiveComponent* InComp, FName InBoneName)
{
InComp->SetSimulatePhysics(false); // отключение физики
InComp->SetCollisionEnabled(ECollisionEnabled::NoCollision); // отключение столкновений
InComp->SetMobility(EComponentMobility::Static); // с этого момента сделать деталь статичной
/* теперь деталь статична и не взаимодействует с миром */
}
Что делать с разрушением
Нам необходимо проверить, можно ли разрушить актера (например, если игрок далеко). Если нет, проведем проверку повторно спустя некоторое время.
Попытаемся разрушить объект в отсутствие игрока
void ADestroyable::DestroyAfterBreaking()
{
if (IsPlayerNear()) // проверка того, находится ли игрок поблизости
{
//повторить проверку позже
GetWorld()->GetTimerManager().SetTimer(DestroyAfterBreakTimerHandle, this, &ADestroyable::DestroyAfterBreaking, DESTROYACTORDELAY, false);
}
else
{
GetWorld()->GetTimerManager().ClearTimer(DestroyAfterBreakTimerHandle); // сброс таймера
Destroy(); // разрушение актера сцены
}
}
Вызов узла OnHit для частей объекта
В нашем случае Blueprints отвечают за аудиовизуальную часть игры, поэтому мы добавляем события Blueprints там, где это возможно.
void ADestroyable::OnPartHitCallback(UPrimitiveComponent* HitComp, AActor* OtherActor, UPrimitiveComponent* OtherComp, FVector NormalImpulse, const FHitResult& Hit)
{
OnPartHitBP(Hit, NormalImpulse, HitComp, OtherComp); // blueprint будет отвечать за аудиовизуальные эффекты
}
End Play и очистка
В нашу игру можно играть в редакторе по умолчанию и некоторых пользовательских редакторах. Вот почему нам нужно очистить в EndPlay все, что можно.
void ADestroyable::EndPlay(const EEndPlayReason::Type EndPlayReason)
{
/* сброс таймеров */
GetWorld()->GetTimerManager().ClearTimer(DestroyAfterBreakTimerHandle);
GetWorld()->GetTimerManager().ClearTimer(ForceSleepTimerHandle);
Super::EndPlay(EndPlayReason);
}
Конфигурация в Blueprints
Конфигурация здесь проста. Вы просто помещаете части, прикрепленные к базовой сетке, и помечаете их как «dest». Вот и все.
Графическим художникам не нужно ничего делать в движке.
Наш базовый класс Blueprint выполняет только аудиовизуальные вещи из событий, которые мы предоставили на C ++.
BeginPlay — загружает необходимые ассеты. По сути, в нашем случае каждый ассет представляет собой указатель на программный объект, и необходимо использовать их даже при создании прототипов. Жестко запрограммированные референсы ассетов увеличат время загрузки редактора/игры и использование памяти.
On Break Event — отвечает на эффекты и звуки появления. Здесь вы можете найти некоторые параметры Niagara, которые будут описаны позже.
On Part Hit Event — вызывает эффекты и звуки ударов.
Утилита для быстрого добавления столкновений
Можно использовать Utility Blueprint для взаимодействия с ассетами, чтобы генерировать коллизии для всех частей объекта. Это намного быстрее, чем создавать их самостоятельно.
Эффекты частиц в Niagara
Далее описывается создание простого эффекта в Niagara.
Материал
Ключевой в этом материале является текстура, а не шейдер, так что он действительно очень простой.
Эрозия, цвет и альфа берутся из Niagara.
Канал текстуры R
Канал текстуры G
Большая часть эффекта достигается текстурой. Можно было бы еще использовать канал B, чтобы добавить больше деталей, но в настоящее время нам он не нужен.
Параметры системы Niagara
Мы используем две системы Niagara: одну для эффекта разрыва (она использует базовую сетку для порождения частиц), а другую — при столкновении деталей (без статичного расположения сетки).
Пользователь может указать цвет и количество спаунов и выбрать статичную сетку, которая будет использоваться для выбора расположения спауна частиц
Niagara Spawn Burst
Здесь задействуется пользователь int32 для того, чтобы иметь возможность настроить счетчик появления для каждого разрушаемого объекта
Niagara Particle Spawn
- Отбираем статичную сетку из разрушаемых объектов;
- Устанавливаем случайные время жизни (Lifetime), массу и размер;
- Выбираем цвет из пользовательских (он задается разрушаемым актером);
- Создаем частицы в вершинах сетки,
- Добавляем случайную скорость и скорость вращения.
Использование статичной сетки
Чтобы иметь возможность использовать статичную сетку в Niagara, на вашей сетке должен быть установлен флажок AllowCPU.
СОВЕТ: В текущей (4.24) версии движка, если вы повторно импортируете свою сетку, это значение будет сброшено на значение по умолчанию. А в доставочной сборке, если вы попытаетесь запустить такую систему Niagara с сеткой, у которой не включен доступ к ЦП, произойдет сбой.
Поэтому добавим простой код для проверки, установлено у сетки это значение.
bool UFunctionLibrary::MeshHaveCPUAccess(UStaticMesh* InMesh)
{
return InMesh->bAllowCPUAccess;
}
Он используется в Blueprints до Niagara.
Можно создать виджет редактора для поиска разрушаемых объектов и установить их переменную Base Mesh AllowCPUAccess.
Приведем код на Python, который ищет все разрушаемые объекты и устанавливает доступ ЦП к базовой сетке.
Код на Python для установки переменной allow_cpu_access статичной сетки
import unreal as ue
asset_registry = ue.AssetRegistryHelpers.get_asset_registry()
all_assets = asset_registry.get_assets_by_path('/Game/Blueprints/Actors/Destroyables', recursive=True) #здесь хранятся все blueprints разрушаемых объектов
for asset in all_assets:
path = asset.object_path
bp_gc = ue.EditorAssetLibrary.load_blueprint_class(path) #get blueprint class
bp_cdo = ue.get_default_object(bp_gc) # get the Class Default Object (CDO) of the generated class
if bp_cdo.mesh.static_mesh != None:
ue.EditorStaticMeshLibrary.set_allow_cpu_access(bp_cdo.mesh.static_mesh, True) # sets allow cpu on static mesh
Вы можете запустить его напрямую с помощью команды py или создать кнопку для запуска кода в Utility Widget.
Обновление частиц Niagara
При обновлении мы проделываем следующие вещи:
- Масштабируем Alpha Over Life,
- Добавляем curl noise,
- Изменяем скорость вращения в соответствии с выражением: (Particles.RotRate * (0.8 – Particles.NormalizedAge);
- Масштабируем параметр частиц Size Over Life,
- Обновляем параметр размытия материала,
- Добавляем вектор шума.
Отчего такой довольно олд-скульный подход?
Конечно, можно использовать текущую систему разрушений из UE4, но так можно эффективнее контролировать производительность и визуальные эффекты. На вопрос, нужна ли для ваших нужд столь большая система, как встроенная по умолчанию, вы должны найти ответ сами. Потому что часто ее использование бывает необоснованным.
Что же касается Chaos, подождем, когда он будет готов к полноценному релизу, и тогда посмотрим на его возможности.