[Перевод] Создаем разрушаемые объекты в Unreal Engine 4 и Blender

drts-3hprxrnrbgz4clx6zvx9mc.jpeg

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

Наиболее яркими примерами игр с хорошей разрушаемостью можно назвать Red Fraction: Guerrilla с ее возможностью пробивать тоннель сквозь Марс, Battlefield: Bad Company 2, где при желании можно превратить весь сервер в пепелище, и Control с его процедурным разрушением всего, что попадается на глаза.

В 2019 году Epic Games представила демо новой высокопроизводительной системы физики и разрушений Chaos движка Unreal. Новая система позволяет создавать разрушения разного масштаба, имеет поддержку редактора эффектов Niagara и при этом отличается экономным расходованием ресурсов.

А пока Chaos находится на стадии бета-тестирования, поговорим об альтернативных подходах к созданию разрушаемых объектов в Unreal Engine 4. В этой статье один из них опишем подробно.


Требования


Начнем с перечисления того, чего мы хотели бы достичь:

  • Художественный контроль. Мы хотим, чтобы наши художники могли создавать разрушаемые объекты, как им угодно.
  • Разрушения, не влияющие на геймплей. Они должны быть чисто визуальными, не нарушать ничего, что связано с игровым процессом.
  • Оптимизация. Мы хотим иметь полный контроль над производительностью и не допустить снижения производительности ЦП.
  • Простота установки. Настройка конфигурации таких объектов должна быть понятна художникам, поэтому необходимо, чтобы она включала только необходимый минимум шагов.


За референс в этой статье было взято разрушаемое окружение из Dark Souls 3 и Bloodborne.

image

Основная идея


На самом деле, идея проста:

  • Создаем видимую базовую сетку;
  • Добавляем скрытые части сетки;
  • При разрушении: скрываем базовую сетку → показываем ее части → запускаем физику.


image

image

Подготовка ассетов


Для подготовки объектов будем использовать Blender. Для создания сетки, по которой они будут разрушаться, используем аддон Blender под названием Cell Fracture.

Включение аддона


Сначала нам понадобится включить аддон, поскольку по умолчанию он выключен.

image
Включение аддона Cell Fracture

Поиск аддона (F3)


Затем включаем аддон на выбранной сетке.

image

Настройки конфигурации


image

Запуск аддона


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

Your browser does not support HTML5 video.


Выбор материала для разворачивания разрезанных частей


Затем создадим UV-карту для этих частей.

image

image

Добавляем разделение границ (Edge Split)


Edge Split исправит затенение.

image

Модификаторы ссылок


Их использование позволит применить Edge Split ко всем выбранным частям.

image

Завершение


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

image

Реализация


Базовый класс


Наш разрушаемый объект — это Актер, у которого есть несколько компонентов:

  • Корневая сцена;
  • Static Mesh — базовая сетка;
  • Бокс для столкновений;
  • Бокс для перекрытий;
  • Радиальная сила.


image

Изменим кое-какие настройки в конструкторе:

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


Настройка актера в конструкторе
ADestroyable::ADestroyable()
{
    PrimaryActorTick.bCanEverTick = false; //отключаем Tick
    bDestroyed = false; 

    RootScene = CreateDefaultSubobject(TEXT("RootComp")); // корневая сцена, где все содержится
    RootScene->SetMobility(EComponentMobility::Static);
    RootComponent = RootScene;

    Mesh = CreateDefaultSubobject(TEXT("BaseMeshComp")); // базовая сетка
    Mesh->SetMobility(EComponentMobility::Static);
    Mesh->SetupAttachment(RootScene);

    Collision = CreateDefaultSubobject(TEXT("CollisionComp")); // столкновения, зависящие от пересечений объектов
    Collision->SetMobility(EComponentMobility::Static);
    Collision->SetupAttachment(Mesh);

    OverlapWithNearDestroyable = CreateDefaultSubobject(TEXT("OverlapWithNearDestroyableComp")); // столкновения, ищущие разрушаемые объекты поблизости
    OverlapWithNearDestroyable->SetMobility(EComponentMobility::Static);
    OverlapWithNearDestroyable->SetupAttachment(Mesh);

    Force = CreateDefaultSubobject(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 ADestroyable::GetBreakableComponents()
{
    if (BreakableComponents.Num() == 0) // есть ли у нас что-то в кэше?
    {
        TInlineComponentArray ComponentsByClass; //сохраняем все статичные компоненты сетки
        GetComponents(ComponentsByClass);

        TArray ComponentsByTag; // храним все части с тэгом «dest»
        ComponentsByTag.Reserve(ComponentsByClass.Num());
        for (UStaticMeshComponent* Component : ComponentsByClass)
        {
            if (Component->ComponentHasTag(TEXT("dest")))
            {
                ComponentsByTag.Push(Component);
            }
        }
        BreakableComponents = ComponentsByTag; // храним данные для дальнейшего использования
    }
    return BreakableComponents;
}


Триггеры разрушения


Существует три способа спровоцировать разрушение.

OnOverlap

Разрушение происходит в том случае, когда кто-то бросает или каким-либо другим образом использует предмет, активирующий процесс, — например, катящийся мяч.

image

OnTakeDamage

Разрушаемый объект получает урон.

image

OnOverlapWithNearDestroyable

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

image

Флоу разрушения объекта

image
Диаграмма разрушения объекта

Показ разрушаемых частей
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 OtherOverlapingDestroyables;
    OverlapWithNearDestroyable->GetOverlappingActors(OtherOverlapingDestroyables, ADestroyable::StaticClass()); // получение других объектов в боксе
    for (AActor* OtherActor : OtherOverlapingDestroyables)
    {
        if (OtherActor == this)
            continue;

        if (ADestroyable* OtherDest = Cast(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». Вот и все.

image

Графическим художникам не нужно ничего делать в движке.

Наш базовый класс Blueprint выполняет только аудиовизуальные вещи из событий, которые мы предоставили на C ++.

BeginPlay — загружает необходимые ассеты. По сути, в нашем случае каждый ассет представляет собой указатель на программный объект, и необходимо использовать их даже при создании прототипов. Жестко запрограммированные референсы ассетов увеличат время загрузки редактора/игры и использование памяти.

image

On Break Event — отвечает на эффекты и звуки появления. Здесь вы можете найти некоторые параметры Niagara, которые будут описаны позже.

image

On Part Hit Event — вызывает эффекты и звуки ударов.

image

Утилита для быстрого добавления столкновений


Можно использовать Utility Blueprint для взаимодействия с ассетами, чтобы генерировать коллизии для всех частей объекта. Это намного быстрее, чем создавать их самостоятельно.

image

image

Эффекты частиц в Niagara


Далее описывается создание простого эффекта в Niagara.

Your browser does not support HTML5 video.


Your browser does not support HTML5 video.


Материал


image

image

Ключевой в этом материале является текстура, а не шейдер, так что он действительно очень простой.

Эрозия, цвет и альфа берутся из Niagara.

image
Канал текстуры R

image
Канал текстуры G

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

Параметры системы Niagara


Мы используем две системы Niagara: одну для эффекта разрыва (она использует базовую сетку для порождения частиц), а другую — при столкновении деталей (без статичного расположения сетки).

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

Niagara Spawn Burst


image
Здесь задействуется пользователь int32 для того, чтобы иметь возможность настроить счетчик появления для каждого разрушаемого объекта

Niagara Particle Spawn


image

  • Отбираем статичную сетку из разрушаемых объектов;
  • Устанавливаем случайные время жизни (Lifetime), массу и размер;
  • Выбираем цвет из пользовательских (он задается разрушаемым актером);
  • Создаем частицы в вершинах сетки,
  • Добавляем случайную скорость и скорость вращения.


Использование статичной сетки


Чтобы иметь возможность использовать статичную сетку в Niagara, на вашей сетке должен быть установлен флажок AllowCPU.

image

СОВЕТ: В текущей (4.24) версии движка, если вы повторно импортируете свою сетку, это значение будет сброшено на значение по умолчанию. А в доставочной сборке, если вы попытаетесь запустить такую систему Niagara с сеткой, у которой не включен доступ к ЦП, произойдет сбой.

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

bool UFunctionLibrary::MeshHaveCPUAccess(UStaticMesh* InMesh)
{
    return InMesh->bAllowCPUAccess;
}


Он используется в Blueprints до Niagara.

image

Можно создать виджет редактора для поиска разрушаемых объектов и установить их переменную 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.

image

image

Обновление частиц Niagara


image

image

При обновлении мы проделываем следующие вещи:

  • Масштабируем Alpha Over Life,
  • Добавляем curl noise,
  • Изменяем скорость вращения в соответствии с выражением: (Particles.RotRate * (0.8 — Particles.NormalizedAge);
  • Масштабируем параметр частиц Size Over Life,
  • Обновляем параметр размытия материала,
  • Добавляем вектор шума.


Отчего такой довольно олд-скульный подход?


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

Что же касается Chaos, подождем, когда он будет готов к полноценному релизу, и тогда посмотрим на его возможности.

© Habrahabr.ru