Los juegos modernos se están volviendo más realistas y una forma de lograrlo es crear entornos destructibles. Además, destrozar muebles, plantas, paredes, edificios y ciudades enteras es simplemente divertido.
Los ejemplos más llamativos de juegos con buena destructibilidad son Red Fraction: Guerrilla con su capacidad de perforar un túnel a través de Marte, Battlefield: Bad Company 2, donde puedes convertir todo el servidor en cenizas si quieres, y Control con su destrucción procedimental de todo lo que llama tu atención.
En 2019, Epic Games presentó una demostración del nuevo sistema de destrucción y física de alto rendimiento de Unreal , Chaos . El nuevo sistema permite crear destrucción de diferentes escalas, tiene soporte para el editor de efectos Niagara y al mismo tiempo se distingue por un uso económico de recursos.
Mientras tanto, Chaos está en prueba beta, hablemos de enfoques alternativos para crear objetos destructibles en Unreal Engine 4. En este artículo describiremos uno de ellos en detalle.
Requisitos
Comencemos enumerando lo que nos gustaría lograr:
- Control artístico. Queremos que nuestros artistas puedan crear objetos destructibles como les plazca.
- Destrucción que no afecta el juego. Deben ser puramente visuales, sin perturbar nada relacionado con el juego.
- Mejoramiento. Queremos tener un control total sobre el rendimiento y no dejar que la CPU se apague.
- Fácil de instalar. La configuración de tales objetos debe ser comprensible para los artistas, por lo tanto, es necesario que incluya solo los pasos mínimos necesarios.
Los entornos destructibles de Dark Souls 3 y Bloodborne se tomaron como referencia en este artículo.
idea principal
De hecho, la idea es simple:
- Crea una malla de línea de base visible
- Agrega partes ocultas de la malla;
- Al destruir: ocultar la malla base -> mostrar sus partes -> iniciar la física.
Preparando activos
Usaremos Blender para preparar objetos. Para crear una malla a lo largo de la cual colapsarán, usamos un complemento de Blender llamado Cell Fracture.
Habilitando el complemento
Primero debemos habilitar el complemento, ya que está deshabilitado de forma predeterminada. Habilitación del complemento Cell Fracture
Complemento de búsqueda (F3)
Luego habilite el complemento en la cuadrícula seleccionada.
Ajustes de configuración
Lanzamiento del complemento
Mira el video, verifica la configuración desde allí. Asegúrese de configurar sus materiales correctamente.
Selección de material para desplegar piezas cortadas
Luego crearemos un mapa UV para estas partes.
Agregar división de borde
Edge Split arreglará el sombreado.
Modificadores de enlace
Usarlos aplicará Edge Split a todas las partes seleccionadas.
Terminación
Así es como se ve en Blender. Básicamente, no necesitamos modelar todas las piezas por separado.
Implementación
Clase base
Nuestro objeto destructible es un actor, que tiene varios componentes:
- Escena de la raíz;
- Malla estática - malla base;
- Caja de colisión;
- Caja de suelo;
- Fuerza radial.
Cambiemos algunas configuraciones en el constructor:
- Desactive la función de temporizador Tick (nunca olvide desactivarla para los actores que no la necesitan);
- Configuramos la movilidad estática para todos los componentes;
- Desactive la influencia en la navegación;
- Configuración de perfiles de colisión.
Configurar un actor en el constructor
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);
}
En Begin Play, recopilamos algunos datos y los personalizamos:
- Buscamos todas las partes con la etiqueta "dest";
- Configure colisiones para todas las partes para que el artista no tenga que pensar en ello;
- Establecer movilidad estática;
- Ocultar todas las partes.
Configurar partes de un objeto en 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); // ,
}
}
Función simple para obtener 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;
}
Desencadenantes de destrucción
Hay tres formas de provocar la destrucción.
La
destrucción de OnOverlap ocurre cuando alguien lanza o usa un objeto que activa el proceso, como una bola rodante.
OnTakeDamage El
objeto que se destruye sufre daños.
OnOverlapWithNearDestroyable
En este caso, un objeto que es destructible se superpone a otro. En nuestro caso, por simplicidad, ambos se rompen.
Flujo de destrucción de objetos
Diagrama de destrucción de objetos
Muestra de partes destructibles
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); //
}
}
La función principal de la destrucción.
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
}
Qué hacer con la función dormir
Cuando se activa la función de suspensión, desactivamos la física / colisiones y configuramos la movilidad estática. Esto aumentará la productividad.
Cada componente primitivo de la física puede dormirse. Nos unimos a esta función en la destrucción.
Esta función puede ser inherente a cualquier primitiva. Nos unimos a él para completar la acción sobre el objeto.
A veces, el objeto físico no se duerme y continúa actualizándose, incluso si no ve ningún movimiento. Si continúa simulando la física, hacemos que todas sus partes se duerman después de 15 segundos.
Función de suspensión forzada llamada por temporizador
void ADestroyable::OnPartPutToSleep(UPrimitiveComponent* InComp, FName InBoneName)
{
InComp->SetSimulatePhysics(false); //
InComp->SetCollisionEnabled(ECollisionEnabled::NoCollision); //
InComp->SetMobility(EComponentMobility::Static); //
/* */
}
Qué hacer con la destrucción
Necesitamos comprobar si el actor puede ser destruido (por ejemplo, si el jugador está lejos). Si no, lo comprobaremos nuevamente después de un tiempo.
Intentemos destruir el objeto en ausencia del jugador.
void ADestroyable::DestroyAfterBreaking()
{
if (IsPlayerNear()) // ,
{
//
GetWorld()->GetTimerManager().SetTimer(DestroyAfterBreakTimerHandle, this, &ADestroyable::DestroyAfterBreaking, DESTROYACTORDELAY, false);
}
else
{
GetWorld()->GetTimerManager().ClearTimer(DestroyAfterBreakTimerHandle); //
Destroy(); //
}
}
Llamar al nodo OnHit para partes de un objeto
En nuestro caso, los Blueprints son responsables de la parte audiovisual del juego, por lo que agregamos eventos de Blueprints siempre que sea posible.
void ADestroyable::OnPartHitCallback(UPrimitiveComponent* HitComp, AActor* OtherActor, UPrimitiveComponent* OtherComp, FVector NormalImpulse, const FHitResult& Hit)
{
OnPartHitBP(Hit, NormalImpulse, HitComp, OtherComp); // blueprint
}
Fin de juego y limpieza
Nuestro juego se puede jugar en el editor predeterminado y algunos editores personalizados. Es por eso que necesitamos borrar todo lo que podamos en EndPlay.
void ADestroyable::EndPlay(const EEndPlayReason::Type EndPlayReason)
{
/* */
GetWorld()->GetTimerManager().ClearTimer(DestroyAfterBreakTimerHandle);
GetWorld()->GetTimerManager().ClearTimer(ForceSleepTimerHandle);
Super::EndPlay(EndPlayReason);
}
Configuración en Blueprints
La configuración es simple aquí. Simplemente coloque las piezas unidas a la malla base y márquelas como "dest". Eso es todo. Los artistas gráficos no necesitan hacer nada en el motor. Nuestra clase básica Blueprint solo hace material audiovisual de eventos que proporcionamos en C ++. BeginPlay : descarga los recursos necesarios. De hecho, en nuestro caso, cada activo es un puntero a un objeto de programa y es necesario utilizarlos incluso al crear prototipos. Las referencias de activos codificadas aumentarán los tiempos de carga del editor / juego y el uso de memoria. On Break Event : responde a los efectos y sonidos de apariencia. Puede encontrar algunas opciones de Niagara aquí que se describirán más adelante. En evento de éxito parcial
- Activa efectos de impacto y sonidos.
Una utilidad para agregar colisiones rápidamente
Puede utilizar Utility Blueprint para interactuar con los activos y generar colisiones para todas las partes del objeto. Es mucho más rápido que crearlos usted mismo.
Efectos de las partículas en Niágara
A continuación se describe cómo crear un efecto simple en Niagara .
Material
La clave de este material es la textura, no el sombreador, por lo que es realmente muy simple.
La erosión, el color y el alfa se toman de Niagara.
Canal de textura R Canal de textura
G
La mayor parte del efecto se logra mediante la textura. El Canal B aún podría usarse para agregar más detalles, pero no lo necesitamos en este momento.
Parámetros del sistema Niagara
Usamos dos sistemas Niagara: uno para el efecto de explosión (usa una malla base para generar partículas) y el otro cuando las partes chocan (sin posición de malla estática).
El usuario puede especificar el color y el número de engendros y seleccionar una malla estática que se utilizará para seleccionar la ubicación del engendro de partículas.
Explosión de generación de Niagara
Aquí el usuario int32 está involucrado para poder ajustar el contador de apariencia para cada objeto destructible
Engendro de partículas del Niágara
- Seleccionar una malla estática de objetos destructibles;
- Establezca la duración, el peso y el tamaño al azar;
- Elija un color de los personalizados (lo establece el actor destructible);
- Crea partículas en los vértices de la malla,
- Agregue velocidad aleatoria y velocidad de rotación.
Usando una cuadrícula estática
Para poder usar una malla estática en Niagara, su malla debe tener marcada la casilla de verificación AllowCPU.
SUGERENCIA: En la versión actual (4.24) del motor, si vuelve a importar su malla, este valor se restablecerá al predeterminado. Y en una versión de envío, si intenta ejecutar un sistema Niagara con una malla que no tiene habilitado el acceso a la CPU, se bloqueará.
Así que agreguemos un código simple para verificar si la cuadrícula tiene este valor.
bool UFunctionLibrary::MeshHaveCPUAccess(UStaticMesh* InMesh)
{
return InMesh->bAllowCPUAccess;
}
Se usó en Blueprints antes de Niagara.
Puede crear un widget de editor para encontrar objetos destructibles y establecer su variable Base Mesh en AllowCPUAccess.
Aquí hay un código de Python que busca todos los objetos destructibles y establece el acceso de la CPU a la malla subyacente.
Código Python para establecer la variable allow_cpu_access de cuadrícula estática
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
Puede ejecutarlo directamente con el comando py , o crear un botón para ejecutar el código en el widget de utilidad .
Actualización de partículas de Niagara
Al actualizar, hacemos lo siguiente:
- Escalando Alpha Over Life,
- Agregue ruido de rizo,
- Cambie la velocidad de rotación de acuerdo con la expresión: (Particles.RotRate * (0.8 - Particles.NormalizedAge) ;
- Escale el parámetro de partícula Size Over Life,
- Actualizando el parámetro de desenfoque de material,
- Agrega un vector de ruido.
¿Por qué un enfoque tan de la vieja escuela?
Por supuesto, puede usar el sistema de destrucción actual de UE4, pero de esta manera puede controlar mejor el rendimiento y las imágenes. Cuando se le pregunte si necesita un sistema tan grande como el integrado para sus necesidades, debe encontrar la respuesta usted mismo. Porque su uso a menudo no es razonable.
En cuanto a Chaos, esperemos hasta que esté listo para un lanzamiento completo, y luego veremos sus capacidades.