Os jogos modernos estão se tornando mais realistas e uma maneira de conseguir isso é criar ambientes destrutíveis. Além disso, destruir móveis, plantas, paredes, edifícios e cidades inteiras é muito divertido.
Os exemplos mais notáveis de jogos com boa destrutibilidade são Red Fraction: Guerrilla, com sua capacidade de criar um túnel através de Marte, Battlefield: Bad Company 2, onde você pode transformar todo o servidor em cinzas se quiser, e Control com sua destruição processual de tudo que chamar sua atenção.
Em 2019, a Epic Games revelou uma demonstração do novo sistema de física e destruição de alto desempenho do Unreal , Chaos . O novo sistema permite criar destruição de diferentes escalas, tem suporte para o editor de efeitos Niágara, e ao mesmo tempo é econômico no gasto de recursos.
Enquanto isso, Chaos está em teste beta, vamos falar sobre abordagens alternativas para criar objetos destrutíveis no Unreal Engine 4. Neste artigo, descreveremos um deles em detalhes.
Requisitos
Vamos começar listando o que gostaríamos de alcançar:
- Controle artístico. Queremos que nossos artistas sejam capazes de criar objetos destrutíveis como quiserem.
- Destruição que não afeta o jogo. Eles devem ser puramente visuais, não perturbando nada relacionado à jogabilidade.
- Otimização. Queremos ter controle total sobre o desempenho e não deixar o CPU cair.
- Fácil de instalar. A configuração de tais objetos deve ser compreensível para os artistas, portanto é necessário que inclua apenas o mínimo de etapas necessárias.
Ambientes destrutíveis de Dark Souls 3 e Bloodborne foram tomados como referência neste artigo.
Ideia principal
Na verdade, a ideia é simples:
- Crie uma malha de linha de base visível;
- Adicione partes ocultas da malha;
- Na destruição: esconda a malha base -> mostre suas partes -> inicie a física.
Preparando ativos
Usaremos o Blender para preparar objetos. Para criar uma malha ao longo da qual eles entrarão em colapso, usamos um complemento do Blender chamado Cell Fracture.
Habilitando o addon
Primeiro, precisamos habilitar o addon, pois ele está desabilitado por padrão. Complemento Habilitando Fratura Celular
Addon de pesquisa (F3)
Em seguida, ative o addon na grade selecionada.
Definições de configuração
Lançamento do complemento
Assista ao vídeo, verifique as configurações a partir daí. Certifique-se de configurar seus materiais corretamente.
Seleção de material para desdobramento de peças cortadas
Em seguida, criaremos um mapa UV para essas partes.
Adicionando Divisão de Borda
O Edge Split corrigirá o sombreamento.
Modificadores de link
Usá-los aplicará a divisão de aresta a todas as peças selecionadas.
Conclusão
É assim que fica no Blender. Basicamente, não precisamos modelar todas as peças separadamente.
Implementação
Classe base
Nosso objeto destrutível é um Ator, que possui vários componentes:
- Cena raiz;
- Malha estática - malha de base;
- Caixa de colisão;
- Caixa de chão;
- Força radial.
Vamos alterar algumas configurações no construtor:
- Desabilite o recurso Tick timer (nunca se esqueça de desabilitá-lo para atores que não precisam dele);
- Configuramos mobilidade estática para todos os componentes;
- Desative a influência na navegação;
- Configurando perfis de colisão.
Configurando um ator no construtor
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);
}
No Begin Play, coletamos alguns dados e os personalizamos:
- Procuramos todas as peças com a etiqueta "dest";
- Configure colisões para todas as partes para que o artista não tenha que pensar sobre isso;
- Estabeleça mobilidade estática;
- Oculte todas as peças.
Configurando partes de um objeto em 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); // ,
}
}
Função simples para obter peças componentes
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;
}
Gatilhos de destruição
Existem três maneiras de provocar destruição.
A
destruição OnOverlap ocorre quando alguém arremessa ou usa um objeto que ativa o processo, como uma bola rolando.
OnTakeDamage O
objeto sendo destruído sofre dano.
OnOverlapWithNearDestroyable
Nesse caso, um objeto sendo destrutível se sobrepõe a outro. No nosso caso, para simplificar, ambos quebram.
Fluxo de destruição de objetos
Diagrama de destruição de objetos
Mostrar peças destrutíveis
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); //
}
}
A principal função de destruição
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
}
O que fazer com a função sono
Quando a função Sleep é acionada, desabilitamos a física / colisões e configuramos a mobilidade estática. Isso aumentará a produtividade.
Cada componente primitivo da física pode adormecer. Nós nos ligamos a essa função na destruição.
Esta função pode ser inerente a qualquer primitiva. Ligamos a ele para completar a ação no objeto.
Às vezes, o objeto físico não adormece e continua a se atualizar, mesmo que você não veja nenhum movimento. Se continuar a simular a física, fazemos todas as suas partes dormirem após 15 segundos.
Função de sono forçado chamada por temporizador
void ADestroyable::OnPartPutToSleep(UPrimitiveComponent* InComp, FName InBoneName)
{
InComp->SetSimulatePhysics(false); //
InComp->SetCollisionEnabled(ECollisionEnabled::NoCollision); //
InComp->SetMobility(EComponentMobility::Static); //
/* */
}
O que fazer com a destruição
Precisamos verificar se o ator pode ser destruído (por exemplo, se o jogador estiver longe). Caso contrário, verificaremos novamente após algum tempo.
Vamos tentar destruir o objeto na ausência do jogador
void ADestroyable::DestroyAfterBreaking()
{
if (IsPlayerNear()) // ,
{
//
GetWorld()->GetTimerManager().SetTimer(DestroyAfterBreakTimerHandle, this, &ADestroyable::DestroyAfterBreaking, DESTROYACTORDELAY, false);
}
else
{
GetWorld()->GetTimerManager().ClearTimer(DestroyAfterBreakTimerHandle); //
Destroy(); //
}
}
Chamando OnHit Node para partes de um objeto
No nosso caso, os Blueprints são responsáveis pela parte audiovisual do jogo, então adicionamos eventos Blueprints sempre que possível.
void ADestroyable::OnPartHitCallback(UPrimitiveComponent* HitComp, AActor* OtherActor, UPrimitiveComponent* OtherComp, FVector NormalImpulse, const FHitResult& Hit)
{
OnPartHitBP(Hit, NormalImpulse, HitComp, OtherComp); // blueprint
}
Fim do jogo e limpeza
Nosso jogo pode ser jogado no editor padrão e em alguns editores personalizados. É por isso que precisamos limpar tudo o que pudermos no EndPlay.
void ADestroyable::EndPlay(const EEndPlayReason::Type EndPlayReason)
{
/* */
GetWorld()->GetTimerManager().ClearTimer(DestroyAfterBreakTimerHandle);
GetWorld()->GetTimerManager().ClearTimer(ForceSleepTimerHandle);
Super::EndPlay(EndPlayReason);
}
Configuração em Blueprints
A configuração é simples aqui. Você simplesmente coloca as peças presas à malha de base e as marca como "dest". Isso é tudo. Os artistas gráficos não precisam fazer nada no motor. Nossa classe base do Blueprint só faz material audiovisual de eventos que fornecemos em C ++. BeginPlay - baixa os ativos necessários. Na verdade, em nosso caso, cada ativo é um ponteiro para um objeto de programa e você precisa usá-los mesmo ao criar protótipos. As referências de ativos embutidos no código aumentarão os tempos de carregamento do editor / jogo e o uso de memória. On Break Event - responde a efeitos e sons de aparência. Você pode encontrar algumas opções de Niagara aqui que serão descritas mais tarde. On Part Hit Event
- aciona efeitos de impacto e sons.
Um utilitário para adicionar colisões rapidamente
Você pode usar o Utility Blueprint para interagir com ativos para gerar colisões para todas as partes do objeto. É muito mais rápido do que criá-los você mesmo.
Efeitos de partículas em Niágara
O seguinte descreve como criar um efeito simples no Niágara .
Material
A chave para este material é a textura, não o shader, então é realmente muito simples.
Erosão, cor e alfa são retirados do Niágara.
Canal de textura R Canal de textura
G
A maior parte do efeito é obtida pela textura. O Canal B ainda pode ser usado para adicionar mais detalhes, mas não precisamos dele no momento.
Parâmetros do sistema Niágara
Usamos dois sistemas Niagara: um para o efeito de explosão (usa uma malha de base para gerar partículas) e o outro quando as partes colidem (sem posição de malha estática).
O usuário pode especificar a cor e o número de spawns e selecionar uma malha estática que será usada para selecionar a localização do spawn de partícula
Niagara spawn burst
Aqui, o usuário int32 está envolvido para poder ajustar o contador de aparência para cada objeto destrutível
Niagara Particle Spawn
- Selecionando uma malha estática de objetos destrutíveis;
- Defina o tempo de vida, peso e tamanho aleatórios;
- Escolha uma cor entre as personalizadas (é definida pelo ator destrutível);
- Crie partículas nos vértices da malha,
- Adicione velocidade aleatória e velocidade de rotação.
Usando uma grade estática
Para poder usar a malha estática em Niagara, sua malha deve ter a caixa de seleção AllowCPU marcada.
DICA: Na versão atual (4.24) do motor, se você reimportar sua malha, este valor será redefinido para o padrão. E em uma compilação de remessa, se você tentar executar um sistema Niagara com uma malha que não tenha acesso à CPU habilitado, ele travará.
Portanto, vamos adicionar um código simples para verificar se a grade está configurada com esse valor.
bool UFunctionLibrary::MeshHaveCPUAccess(UStaticMesh* InMesh)
{
return InMesh->bAllowCPUAccess;
}
Foi usado em projetos antes de Niagara.
Você pode criar um widget de editor para localizar objetos destrutíveis e definir sua variável Base Mesh para AllowCPUAccess.
Aqui está um código Python que procura todos os objetos destrutíveis e define o acesso da CPU para a malha subjacente.
Código Python para definir a variável de grade estática 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
Você pode executá-lo diretamente com o comando py ou criar um botão para executar o código no widget Utilitário .
Atualização Niagara Particle
Ao atualizar, fazemos o seguinte:
- Dimensionando Alpha ao longo da vida,
- Adicione ruído de ondulação,
- Altere a velocidade de rotação de acordo com a expressão: (Particles.RotRate * (0.8 - Particles.NormalizedAge) ;
- Dimensione o parâmetro de partículas Size Over Life,
- Atualizando o parâmetro material blur,
- Adicione um vetor de ruído.
Por que uma abordagem tão tradicional?
Claro, você pode usar o sistema de destruição atual do UE4, mas desta forma você pode controlar melhor o desempenho e os visuais. Quando perguntado se você precisa de um sistema tão grande quanto o integrado para suas necessidades, você deve encontrar a resposta sozinho. Porque seu uso muitas vezes não é razoável.
Quanto ao Chaos, vamos esperar até que ele esteja pronto para um lançamento completo, e então veremos seus recursos.