- PVSM.RU - https://www.pvsm.ru -
В этой статье я расскажу, как создавать порталы в Unreal Engine 4. Я не нашёл никаких источников, подробно описывающих такую систему (наблюдение сквозь порталы и проход через них), поэтому решил написать собственную.
Давайте начнём с примеров и объяснения того, что такое портал. Проще всего описать порталы как способ прохода из одного пространство в другое. В некоторых популярных играх эта концепция используется для визуальных эффектов и даже для геймплейных механик:
Antichamber (2013 год) и Portal (2007 год)
Prey, 2006 год
Из трёх игр самой известной, вероятно, является Portal, однако лично меня всегда восхищала Prey и именно её я мечтал скопировать. Однажды я попробовал реализовать собственную версию в Unreal Engine 4, но не особо преуспел, потому что в движке не хватало функционала. Тем не менее, мне удалось провести вот такие эксперименты:
Однако только в новых версиях Unreal Engine мне наконец-то удалось добиться нужного эффекта:
Прежде чем приступать к конкретике, давайте рассмотрим общую картину того, как работают порталы.
По сути, портал — это окно, которое выходит не наружу, а в другое место, то есть мы локально задаём определённую точку обзора относительно объекта и реплицируем эту точку обзора где-то ещё. Пользуясь этим принципом, мы можем соединить два пространства, даже если они находятся очень далеко друг от друга. Окно напоминает маску, которая позволяет нам узнать, где и когда отображать другое пространство вместо исходного. Так как исходная точка обзора реплицируется в другое место, это даёт нам иллюзию непрерывности.
На этом изображении устройство захвата (SceneCapture в UE4) расположено перед пространством, которое соответствует пространству, видимому с точки зрения игрока. Всё, что видимо после линии, заменяется тем, что может видеть захват. Так как устройство захвата может быть расположено между дверью и другими объектами, важно использовать так называемую «плоскость отсечения» (clipping plane). В случае портала мы хотим, чтобы близкая плоскость отсечения маскировала объекты, видимые перед порталом.
Подведём итог. Нам нужны:
Как реализовать это в Unreal Engine?
Я построил свою систему на основании двух основных классов, управляемых PlayerController и Character. Класс Portal — это истинная точка входа в портал, точкой обзора/выхода которого является актор Target. Также здесь есть Portal Manager, который порождается PlayerController и обновляется Character для управления каждым порталом на уровне и их обновлением, а также для манипулирования объектом SceneCapture (который является общим для всех порталов).
Учтите, что в туториале ожидается, что у вас есть доступ к классам Character и PlayerController из кода. В моём случае они называются ExedreCharacter и ExedrePlayerController.
Давайте начнём с актора портала, который будет использоваться для задания «окон», через которые мы будем смотреть на уровень. Задача актора — предоставление информации относительно игрока для вычисления различных позиций и поворотов. Также он будет заниматься распознаванием того, пересекает ли игрок портал, и его телепортацией.
Прежде чем начать подробно рассматривать актора, позвольте объяснить несколько концепций, которые я создал для управления системой порталов:
Воспользовавшись этими правилами, я создал в качестве начальной точки новый класс ExedrePortal, наследуемый от AActor. Вот его заголовок:
#pragma once
#include "CoreMinimal.h"
#include "GameFramework/Actor.h"
#include "ExedrePortal.generated.h"
UCLASS()
class EXEDRE_API AExedrePortal : public AActor
{
GENERATED_UCLASS_BODY()
protected:
virtual void BeginPlay() override;
public:
virtual void Tick(float DeltaTime) override;
//Status of the Portal (being visualized by the player or not)
UFUNCTION(BlueprintPure, Category="Exedre|Portal")
bool IsActive();
UFUNCTION(BlueprintCallable, Category="Exedre|Portal")
void SetActive( bool NewActive );
//Render target to use to display the portal
UFUNCTION(BlueprintNativeEvent, BlueprintCallable, Category="Exedre|Portal")
void ClearRTT();
UFUNCTION(BlueprintNativeEvent, BlueprintCallable, Category="Exedre|Portal")
void SetRTT( UTexture* RenderTexture );
UFUNCTION(BlueprintNativeEvent, Category="Exedre|Portal")
void ForceTick();
//Target of where the portal is looking
UFUNCTION(BlueprintPure, Category="Exedre|Portal")
AActor* GetTarget();
UFUNCTION(BlueprintCallable, Category="Exedre|Portal")
void SetTarget( AActor* NewTarget );
//Helpers
UFUNCTION(BlueprintCallable, Category="Exedre|Portal")
bool IsPointInFrontOfPortal( FVector Point, FVector PortalLocation, FVector PortalNormal );
UFUNCTION(BlueprintCallable, Category="Exedre|Portal")
bool IsPointCrossingPortal( FVector Point, FVector PortalLocation, FVector PortalNormal );
UFUNCTION(BlueprintCallable, Category="Exedre|Portal")
void TeleportActor( AActor* ActorToTeleport );
protected:
UPROPERTY(BlueprintReadOnly)
USceneComponent* PortalRootComponent;
private:
bool bIsActive;
AActor* Target;
//Used for Tracking movement of a point
FVector LastPosition;
bool LastInFront;
};
Как видите, здесь есть большинство описанных поведений. Теперь давайте посмотрим, как они обрабатываются в теле (.cpp).
Root портала задан как динамический, на случай, если класс Blueprint анимирует его (например, использует анимацию открытия/закрытия).
// Sets default values
AExedrePortal::AExedrePortal(const FObjectInitializer& ObjectInitializer)
: Super(ObjectInitializer)
{
PrimaryActorTick.bCanEverTick = true;
bIsActive = false;
RootComponent = CreateDefaultSubobject<USceneComponent>(TEXT("RootComponent"));
RootComponent->Mobility = EComponentMobility::Static;
PortalRootComponent = CreateDefaultSubobject<USceneComponent>(TEXT("PortalRootComponent"));
PortalRootComponent->SetupAttachment( GetRootComponent() );
PortalRootComponent->SetRelativeLocation( FVector(0.0f, 0.0f, 0.0f) );
PortalRootComponent->SetRelativeRotation( FRotator(0.0f, 0.0f, 0.0f) );
PortalRootComponent->Mobility = EComponentMobility::Movable;
}
bool AExedrePortal::IsActive()
{
return bIsActive;
}
void AExedrePortal::SetActive( bool NewActive )
{
bIsActive = NewActive;
}
void AExedrePortal::ClearRTT_Implementation()
{
}
void AExedrePortal::SetRTT_Implementation( UTexture* RenderTexture )
{
}
void AExedrePortal::ForceTick_Implementation()
{
}
AActor* AExedrePortal::GetTarget()
{
return Target;
}
void AExedrePortal::SetTarget( AActor* NewTarget )
{
Target = NewTarget;
}
bool AExedrePortal::IsPointInFrontOfPortal( FVector Point, FVector PortalLocation, FVector PortalNormal )
{
FPlane PortalPlane = FPlane( PortalLocation, PortalNormal );
float PortalDot = PortalPlane.PlaneDot( Point );
//If < 0 means we are behind the Plane
//See : http://api.unrealengine.com/INT/API/Runtime/Core/Math/FPlane/PlaneDot/index.html
return ( PortalDot >= 0 );
}
Функция создаёт направление/сегмент между предыдущим и текущем местоположением, а затем проверяет, пересекают ли они плоскость. Если да, то мы проверяем, пересекает ли в верном направлении (спереди назад?).
bool AExedrePortal::IsPointCrossingPortal( FVector Point, FVector PortalLocation, FVector PortalNormal )
{
FVector IntersectionPoint;
FPlane PortalPlane = FPlane( PortalLocation, PortalNormal );
float PortalDot = PortalPlane.PlaneDot( Point );
bool IsCrossing = false;
bool IsInFront = PortalDot >= 0;
bool IsIntersect = FMath::SegmentPlaneIntersection( LastPosition,
Point,
PortalPlane,
IntersectionPoint );
//Did we intersect the portal since last Location ?
//If yes, check the direction : crossing forward means we were in front and now at the back
//If we crossed backward, ignore it (similar to Prey 2006)
if( IsIntersect && !IsInFront && LastInFront )
{
IsCrossing = true;
}
//Store values for Next check
LastInFront = IsInFront;
LastPosition = Point;
return IsCrossing;
}
Последняя часть актора портала, которую мы рассмотрим — это функция TeleportActor().
При телепортировании актора из точки A в точку B нужно реплицировать его движение и позицию. Например, если в портал проходит игрок, то в сочетании с подходящими визуальными эффектами ему будет казаться, что он прошёл в обычную дверь.
Пересечение портала ощущается как движение по прямой линии, но в реальности происходит совсем другое. При выходе из портала игрок может может оказаться в очень отличающемся контексте. Рассмотрим пример из Portal:
Как видите, при пересечении портала камера поворачивается относительно своего вектора forward (вращается). Так получается, потому что начальная и конечная точка параллельны разным плоскостям:
Поэтому чтобы всё сработало, нам нужно преобразовать движение игрока в относительное пространство портала, чтобы преобразовать его в пространство Target. Реализовав это, мы сможем быть уверенными в том, что после входа в портал и выхода с другой стороны игрок будет правильно выровнен относительно пространства. Это относится не только к позиции и повороту актора, но и к его скорости.
Если мы телепортируем актора без изменений, преобразовав его локальный поворот, то в результате актор может очутиться вверх ногами. Это может подходить для объектов, но неприменимо ни для персонажей, ни для самого игрока. Необходимо изменить позицию актора, как это показано выше на примере из Portal.
void AExedrePortal::TeleportActor( AActor* ActorToTeleport )
{
if( ActorToTeleport == nullptr || Target == nullptr )
{
return;
}
//-------------------------------
//Retrieve and save Player Velocity
//(from the Movement Component)
//-------------------------------
FVector SavedVelocity = FVector::ZeroVector;
AExedreCharacter* EC = nullptr;
if( ActorToTeleport->IsA( AExedreCharacter::StaticClass() ) )
{
EC = Cast<AExedreCharacter>( ActorToTeleport );
SavedVelocity = EC->GetCharMovementComponent()->GetCurrentVelocity();
}
//-------------------------------
//Compute and apply new location
//-------------------------------
FHitResult HitResult;
FVector NewLocation = UTool::ConvertLocationToActorSpace( ActorToTeleport->GetActorLocation(),
this,
Target );
ActorToTeleport->SetActorLocation( NewLocation,
false,
&HitResult,
ETeleportType::TeleportPhysics );
//-------------------------------
//Compute and apply new rotation
//-------------------------------
FRotator NewRotation = UTool::ConvertRotationToActorSpace( ActorToTeleport->GetActorRotation(),
this,
Target );
//Apply new rotation
ActorToTeleport->SetActorRotation( NewRotation );
//-------------------------------
//If we are teleporting a character we need to
//update its controller as well and reapply its velocity
//-------------------------------
if( ActorToTeleport->IsA( AExedreCharacter::StaticClass() ) )
{
//Update Controller
AExedrePlayerController* EPC = EC->GetPlayerController();
if( EPC != nullptr )
{
NewRotation = UTool::ConvertRotationToActorSpace( EPC->GetControlRotation(),
this,
Target );
EPC->SetControlRotation( NewRotation );
}
//Reapply Velocity (Need to reorient direction into local space of Portal)
{
FVector Dots;
Dots.X = FVector::DotProduct( SavedVelocity, GetActorForwardVector() );
Dots.Y = FVector::DotProduct( SavedVelocity, GetActorRightVector() );
Dots.Z = FVector::DotProduct( SavedVelocity, GetActorUpVector() );
FVector NewVelocity = Dots.X * Target->GetActorForwardVector()
+ Dots.Y * Target->GetActorRightVector()
+ Dots.Z * Target->GetActorUpVector();
EC->GetCharMovementComponent()->Velocity = NewVelocity;
}
}
//Cleanup Teleport
LastPosition = NewLocation;
}
FVector ConvertLocationToActorSpace( FVector Location, AActor* Reference, AActor* Target )
{
if( Reference == nullptr || Target == nullptr )
{
return FVector::ZeroVector;
}
FVector Direction = Location - Reference->GetActorLocation();
FVector TargetLocation = Target->GetActorLocation();
FVector Dots;
Dots.X = FVector::DotProduct( Direction, Reference->GetActorForwardVector() );
Dots.Y = FVector::DotProduct( Direction, Reference->GetActorRightVector() );
Dots.Z = FVector::DotProduct( Direction, Reference->GetActorUpVector() );
FVector NewDirection = Dots.X * Target->GetActorForwardVector()
+ Dots.Y * Target->GetActorRightVector()
+ Dots.Z * Target->GetActorUpVector();
return TargetLocation + NewDirection;
}
Преобразование здесь выполняется вычислением скалярного произведения векторов для определения различных углов. Вектор Direction не нормализован, то есть мы можем снова умножить результат Dots на векторы Target, чтобы получить позицию на точно таком же расстоянии в локальном пространстве актора Target.
FRotator ConvertRotationToActorSpace( FRotator Rotation, AActor* Reference, AActor* Target )
{
if( Reference == nullptr || Target == nullptr )
{
return FRotator::ZeroRotator;
}
FTransform SourceTransform = Reference->GetActorTransform();
FTransform TargetTransform = Target->GetActorTransform();
FQuat QuatRotation = FQuat( Rotation );
FQuat LocalQuat = SourceTransform.GetRotation().Inverse() * QuatRotation;
FQuat NewWorldQuat = TargetTransform.GetRotation() * LocalQuat;
return NewWorldQuat.Rotator();
}
Преобразование поворота реализовать было чуть труднее. В конце концов наилучшим решением оказалось использование кватернионов [1], потому что это получается намного точнее, чем работа с обычными углами Эйлера [2], и требует всего нескольких строк кода. Повороты кватернионами выполняются с помощью умножения, поэтому в нашем случае применив Inverse() к тому повороту, который мы хотим преобразовать, мы переместим его в локальное пространство. Далее нам просто достаточно снова умножить его на поворот Target, чтобы получить окончательный поворот.
Чтобы выглядеть красиво с точки зрения игрока, моя система порталов использует определённый меш. Меш разделён на две разные плоскости:
Зачем же использовать меш таким образом?
Я решил, что «плоскость 1» будет растягиваться при приближении игрока. Это позволяет игроку накладываться на портал и проходить по нему без его усечения (вырезания). Это может произойти, например, если камера пока не пересекла плоскость портала, но её уже коснулись ноги игрока. Это позволяет не заниматься отсеканием игрока и дублированием меша с другой стороны.
Задача «плоскости 2» — расширение стандартного bounding box меша. Так как «плоскость 1» является плоской, bounding box по одной оси имеет толщину 0, и если камера оказывается за ним, то движок её отсечёт (т.е. не будет её рендерить). Плоскость 1 имеет размер 128×128, поэтому её можно легко масштабировать средствами движка. Плоскость 2 немного больше и находится ниже пола (ниже 0).
Создав меш, мы просто экспортируем его из стороннего 3D-редактора и импортируем в Unreal. Он будет использоваться на следующем этапе.
Для отображения другой стороны портала нам нужно создать собственный материал. Создадим новый материал в браузере контента (content browser) (я назвал его MAT_PortalBase):
Теперь откроем его и создадим следующий граф:
Вот как работает материал:
Сделав всё это, мы создали готовый к использованию материал.
Давайте настроим новый класс блюпринта, наследующий от актора Portal. Нажмите правой клавишей на content browser и выберите класс Blueprint:
Теперь введите в поле поиска «portal», чтобы выбрать класс портала:
Откройте блюпринт, если он ещё не открыт. В списке компонентов вы увидите следующую иерархию:
Как мы и ожидали, здесь есть root component и portal root. Давайте добавим в PortalRootComponent компонент статичного меша и загрузим в него меш, созданный на предыдущем этапе:
Collision box находится ниже компонента сцены, связанного с основным root, а не под Portal root. Также я добавил значок (биллборд) и компонент стрелки, чтобы портал был заметнее на уровнях. Разумеется, делать это необязательно.
Теперь давайте настроим материал в блюпринте.
Для начала нам нужны две переменные — одна будет иметь тип Actor и название PortalTarget, вторая имеет тип Dynamic Material Instance и называется MaterialInstance. PortalTarget будет ссылкой на позицию, в которое смотрит окно портала (поэтому переменная общая, со значком открытого глаза), чтобы мы могли изменять её, когда актор будет размещён на уровне. MaterialInstance будет хранить ссылку на динамический материал, чтобы в дальнейшем мы могли назначать render target портала на лету.
Также нам нужно добавить собственные ноды событий. Лучше всего открыть меню правой клавиши мыши в Event Graph и найти названия событий:
И здесь создать следующую схему:
Пока мы закончили с блюпринтом, но позже вернёмся к нему, чтобы реализовать функции Tick.
Итак, теперь у нас есть все базовые элементы, которые необходимы для создания нового класса, наследуемого от AActor, который будет являться Portal Manager. Возможно, в вашем проекте класс Portal Manager будет не нужен, но в моём случае он намного упрощает работу с некоторыми аспектами. Вот список задач, выполняемых Portal manager:
Давайте взглянем на заголовок Portal Manager:
#pragma once
#include "CoreMinimal.h"
#include "GameFramework/Actor.h"
#include "ExedrePortalManager.generated.h"
//Forward declaration
class AExedrePlayerController;
class AExedrePortal;
class UExedreScriptedTexture;
UCLASS()
class EXEDRE_API AExedrePortalManager : public AActor
{
GENERATED_UCLASS_BODY()
public:
AExedrePortalManager();
//Called by a Portal actor when wanting to teleport something
UFUNCTION(BlueprintCallable, Category="Portal")
void RequestTeleportByPortal( AExedrePortal* Portal, AActor* TargetToTeleport );
//Save a reference to the PlayerControler
void SetControllerOwner( AExedrePlayerController* NewOwner );
//Various setup that happens during spawn
void Init();
//Manual Tick
void Update( float DeltaTime );
//Find all the portals in world and update them
//returns the most valid/usable one for the Player
AExedrePortal* UpdatePortalsInWorld();
//Update SceneCapture
void UpdateCapture( AExedrePortal* Portal );
//Accessor for Debug purpose
UTexture* GetPortalTexture();
//Accessor for Debug purpose
FTransform GetCameraTransform();
private:
//Function to create the Portal render target
void GeneratePortalTexture();
UPROPERTY()
USceneCaptureComponent2D* SceneCapture;
//Custom class, can be replaced by a "UCanvasRenderTarget2D" instead
//See : https://api.unrealengine.com/INT/API/Runtime/Engine/Engine/UCanvasRenderTarget2D/index.html
UPROPERTY()
UExedreScriptedTexture* PortalTexture;
UPROPERTY()
AExedrePlayerController* ControllerOwner;
int32 PreviousScreenSizeX;
int32 PreviousScreenSizeY;
float UpdateDelay;
};
FActorSpawnParameters SpawnParams;
PortalManager = nullptr;
PortalManager = GetWorld()->SpawnActor<AExedrePortalManager>( AExedrePortalManager::StaticClass(),
FVector::ZeroVector,
FRotator::ZeroRotator,
SpawnParams);
PortalManager->AttachToActor( this, FAttachmentTransformRules::SnapToTargetIncludingScale);
PortalManager->SetControllerOwner( this );
PortalManager->Init();
Итак, мы создаём актора, прикрепляем его к контроллеру игрока (this), а затем сохраняем ссылку и вызываем функцию Init().
Также важно заметить, что мы обновляем актор вручную из класса Character:
void AExedreCharacter::TickActor( float DeltaTime, enum ELevelTick TickType, FActorTickFunction& ThisTickFunction )
{
Super::TickActor( DeltaTime, TickType, ThisTickFunction );
if( UGameplayStatics::GetPlayerController(GetWorld(), 0) != nullptr )
{
AExedrePlayerController* EPC = Cast<AExedrePlayerController>( UGameplayStatics::GetPlayerController(GetWorld(), 0) );
EPC->PortalManager->Update( DeltaTime );
}
}
А вот конструктор Portal Manager. Заметьте, что Tick отключен, снова потому, что мы вручную будем обновлять Portal Manager через игрока.
AExedrePortalManager::AExedrePortalManager(const FObjectInitializer& ObjectInitializer)
: Super(ObjectInitializer)
{
PrimaryActorTick.bCanEverTick = false;
PortalTexture = nullptr;
UpdateDelay = 1.1f;
PreviousScreenSizeX = 0;
PreviousScreenSizeY = 0;
}
void AExedrePortalManager::SetControllerOwner( AExedrePlayerController* NewOwner )
{
ControllerOwner = NewOwner;
}
FTransform AExedrePortalManager::GetCameraTransform()
{
if( SceneCapture != nullptr )
{
return SceneCapture->GetComponentTransform();
}
else
{
return FTransform();
}
}
UTexture* AExedrePortalManager::GetPortalTexture()
{
//Portal Texture is a custom component class that embed a UCanvasRenderTraget2D
//The GetTexture() simply returns the RenderTarget contained in that class.
//IsValidLowLevel() is used here as a way to ensure the Texture has not been destroyed or garbage collected.
if( PortalTexture != nullptr && PortalTexture->IsValidLowLevel() )
{
return PortalTexture->GetTexture();
}
else
{
return nullptr;
}
}
Основная задача этой функции — создание компонента SceneCapture (то есть упомянутого выше устройства захвата) и его правильная настройка. Она начинается с создания нового объекта и регистрации его как компонента данного актора. Затем мы переходим к заданию свойств, относящихся к этому захвату.
Свойства, которые нужно упомянуть:
Остальные свойства в основном связаны с параметрами постобработки. Они являются удобным способом управлять качеством, а значит и производительностью захвата.
Последняя часть вызывает функцию, которая создаёт Render Target, который мы увидим ниже.
void AExedrePortalManager::Init()
{
//------------------------------------------------
//Create Camera
//------------------------------------------------
SceneCapture = NewObject<USceneCaptureComponent2D>(this, USceneCaptureComponent2D::StaticClass(), *FString("PortalSceneCapture"));
SceneCapture->AttachToComponent( GetRootComponent(), FAttachmentTransformRules::SnapToTargetIncludingScale );
SceneCapture->RegisterComponent();
SceneCapture->bCaptureEveryFrame = false;
SceneCapture->bCaptureOnMovement = false;
SceneCapture->LODDistanceFactor = 3; //Force bigger LODs for faster computations
SceneCapture->TextureTarget = nullptr;
SceneCapture->bEnableClipPlane = true;
SceneCapture->bUseCustomProjectionMatrix = true;
SceneCapture->CaptureSource = ESceneCaptureSource::SCS_SceneColorSceneDepth;
//Setup Post-Process of SceneCapture (optimization : disable Motion Blur, etc)
FPostProcessSettings CaptureSettings;
CaptureSettings.bOverride_AmbientOcclusionQuality = true;
CaptureSettings.bOverride_MotionBlurAmount = true;
CaptureSettings.bOverride_SceneFringeIntensity = true;
CaptureSettings.bOverride_GrainIntensity = true;
CaptureSettings.bOverride_ScreenSpaceReflectionQuality = true;
CaptureSettings.AmbientOcclusionQuality = 0.0f; //0=lowest quality..100=maximum quality
CaptureSettings.MotionBlurAmount = 0.0f; //0 = disabled
CaptureSettings.SceneFringeIntensity = 0.0f; //0 = disabled
CaptureSettings.GrainIntensity = 0.0f; //0 = disabled
CaptureSettings.ScreenSpaceReflectionQuality = 0.0f; //0 = disabled
CaptureSettings.bOverride_ScreenPercentage = true;
CaptureSettings.ScreenPercentage = 100.0f;
SceneCapture->PostProcessSettings = CaptureSettings;
//------------------------------------------------
//Create RTT Buffer
//------------------------------------------------
GeneratePortalTexture();
}
В своём случае я создал класс-обёртку для UCanvasRenderTarget2D. Я назвал его ExedreScriptedTexture, он является компонентом, который можно прикрепить к актору. Я создал этот класс для удобного управления render targets с акторами, у которых есть задачи рендеринга. Он занимается правильной инициализацией Render Target и совместим с моей собственной системой UI. Однако в контексте порталов обычной текстуры RenderTarget2D более чем достаточно. Поэтому можно просто использовать её.
void AExedrePortalManager::GeneratePortalTexture()
{
int32 CurrentSizeX = 1920;
int32 CurrentSizeY = 1080;
if( ControllerOwner != nullptr )
{
ControllerOwner->GetViewportSize(CurrentSizeX, CurrentSizeY);
}
CurrentSizeX = FMath::Clamp( int(CurrentSizeX / 1.7), 128, 1920); //1920 / 1.5 = 1280
CurrentSizeY = FMath::Clamp( int(CurrentSizeY / 1.7), 128, 1080);
if( CurrentSizeX == PreviousScreenSizeX
&& CurrentSizeY == PreviousScreenSizeY )
{
return;
}
PreviousScreenSizeX = CurrentSizeX;
PreviousScreenSizeY = CurrentSizeY;
//Cleanup existing RTT
if( PortalTexture != nullptr && PortalTexture->IsValidLowLevel() )
{
PortalTexture->DestroyComponent();
GEngine->ForceGarbageCollection();
}
//Create new RTT
PortalTexture = nullptr;
PortalTexture = NewObject<UExedreScriptedTexture>(this, UExedreScriptedTexture::StaticClass(), *FString("PortalRenderTarget"));
PortalTexture->SizeX = CurrentSizeX;
PortalTexture->SizeY = CurrentSizeY;
//Custom properties of the UExedreScriptedTexture class
PortalTexture->Gamma = 1.0f;
PortalTexture->WrapModeX = 1; //Clamp
PortalTexture->WrapModeY = 1; //Clamp
PortalTexture->bDrawWidgets = false;
PortalTexture->bGenerateMipMaps = false;
PortalTexture->SetClearOnUpdate( false ); //Will be cleared by SceneCapture instead
PortalTexture->Format = ERenderTargetFormat::RGBA16; //Needs 16b to get >1 for Emissive
PortalTexture->AttachToComponent( GetRootComponent(), FAttachmentTransformRules::SnapToTargetIncludingScale );
PortalTexture->RegisterComponent();
PortalTexture->SetOwner( this );
PortalTexture->Init();
PortalTexture->SetFilterMode( TextureFilter::TF_Bilinear );
}
Как сказано выше, я создал собственный класс, поэтому задаваемые здесь свойства необходимо адаптировать под обычный Render Target.
Важно понимать, где будет отображаться захват. Так как render target будет отображаться в игре, это означает, что это будет происходит до всей постобработки, и поэтому нам нужно рендерить сцену с достаточным объёмом информации (для хранения значений выше 1, чтобы создавать Bloom). Именно поэтому я выбрал формат RGBA16 (заметьте, что у него есть собственный Enum, вам вместо этого понадобится использовать ETextureRenderTargetFormat).
Более подробную информацию см. в следующих источниках:
void AExedrePortalManager::Update( float DeltaTime )
{
//-----------------------------------
//Generate Portal texture ?
//-----------------------------------
UpdateDelay += DeltaTime;
if( UpdateDelay > 1.0f )
{
UpdateDelay = 0.0f;
GeneratePortalTexture();
}
//-----------------------------------
//Find portals in the level and update them
//-----------------------------------
AExedrePortal* Portal = UpdatePortalsInWorld();
if( Portal != nullptr )
{
UpdateCapture( Portal );
}
}
Мы вызываем UpdatePortalsInWorld(), чтобы найти все порталы, присутствующие в текущем мире (в том числе во всех загруженных уровнях), и обновить их. Функция также определяет, какой из них «активен», т.е. видим для игрока. Если мы нашли активный портал, то вызываем UpdateCapture(), которая управляет компонентом SceneCapture.
Проверка, определяющая правильность портала, проста: мы отдаём приоритет ближайшему к игроку порталу, потому что он скорее всего будет самым видимым с его точки обзора. Для отбрасывания близких, но, например, находящихся за игроком порталов, потребуются более сложные проверки, но я не хотел сосредотачиваться на этом в своём туториале, потому что это может стать довольно трудным.
AExedrePortal* AExedrePortalManager::UpdatePortalsInWorld()
{
if( ControllerOwner == nullptr )
{
return nullptr;
}
AExedreCharacter* Character = ControllerOwner->GetCharacter();
//-----------------------------------
//Update Portal actors in the world (and active one if nearby)
//-----------------------------------
AExedrePortal* ActivePortal = nullptr;
FVector PlayerLocation = Character->GetActorLocation();
FVector CameraLocation = Character->GetCameraComponent()->GetComponentLocation();
float Distance = 4096.0f;
for( TActorIterator<AExedrePortal>ActorItr( GetWorld() ); ActorItr; ++ActorItr )
{
AExedrePortal* Portal = *ActorItr;
FVector PortalLocation = Portal->GetActorLocation();
FVector PortalNormal = -1 * Portal->GetActorForwardVector();
//Reset Portal
Portal->ClearRTT();
Portal->SetActive( false );
//Find the closest Portal when the player is Standing in front of
float NewDistance = FMath::Abs( FVector::Dist( PlayerLocation, PortalLocation ) );
if( NewDistance < Distance )
{
Distance = NewDistance;
ActivePortal = Portal;
}
}
return ActivePortal;
}
Это функция обновления, захватывающая другую сторону портала. Из комментариев всё должно быть понятно, но вот краткое описание:
Как мы видим, при телепортации игрока ключевым элементом естественного и безупречного поведения SceneCapture является правильное преобразование позиции и поворота портала в локальное пространство Target.
Определение ConvertLocationToActorSpace() см. в разделе «Телепортируем актора».
void AExedrePortalManager::UpdateCapture( AExedrePortal* Portal )
{
if( ControllerOwner == nullptr )
{
return;
}
AExedreCharacter* Character = ControllerOwner->GetCharacter();
//-----------------------------------
//Update SceneCapture (discard if there is no active portal)
//-----------------------------------
if(SceneCapture != nullptr
&& PortalTexture != nullptr
&& Portal != nullptr
&& Character != nullptr )
{
UCameraComponent* PlayerCamera = Character->GetCameraComponent();
AActor* Target = Portal->GetTarget();
//Place the SceneCapture to the Target
if( Target != nullptr )
{
//-------------------------------
//Compute new location in the space of the target actor
//(which may not be aligned to world)
//-------------------------------
FVector NewLocation = UTool::ConvertLocationToActorSpace( PlayerCamera->GetComponentLocation(),
Portal,
Target );
SceneCapture->SetWorldLocation( NewLocation );
//-------------------------------
//Compute new Rotation in the space of the
//Target location
//-------------------------------
FTransform CameraTransform = PlayerCamera->GetComponentTransform();
FTransform SourceTransform = Portal->GetActorTransform();
FTransform TargetTransform = Target->GetActorTransform();
FQuat LocalQuat = SourceTransform.GetRotation().Inverse() * CameraTransform.GetRotation();
FQuat NewWorldQuat = TargetTransform.GetRotation() * LocalQuat;
//Update SceneCapture rotation
SceneCapture->SetWorldRotation( NewWorldQuat );
//-------------------------------
//Clip Plane : to ignore objects between the
//SceneCapture and the Target of the portal
//-------------------------------
SceneCapture->ClipPlaneNormal = Target->GetActorForwardVector();
SceneCapture->ClipPlaneBase = Target->GetActorLocation()
+ (SceneCapture->ClipPlaneNormal * -1.5f); //Offset to avoid visible pixel border
}
//Switch on the valid Portal
Portal->SetActive( true );
//Assign the Render Target
Portal->SetRTT( PortalTexture->GetTexture() );
SceneCapture->TextureTarget = PortalTexture->GetTexture();
//Get the Projection Matrix
SceneCapture->CustomProjectionMatrix = ControllerOwner->GetCameraProjectionMatrix();
//Say Cheeeeese !
SceneCapture->CaptureScene();
}
}
Функции GetCameraProjectionMatrix() по умолчанию в классе PlayerController не существует, её я добавил сам. Она показана ниже:
FMatrix AExedrePlayerController::GetCameraProjectionMatrix()
{
FMatrix ProjectionMatrix;
if( GetLocalPlayer() != nullptr )
{
FSceneViewProjectionData PlayerProjectionData;
GetLocalPlayer()->GetProjectionData( GetLocalPlayer()->ViewportClient->Viewport,
EStereoscopicPass::eSSP_FULL,
PlayerProjectionData );
ProjectionMatrix = PlayerProjectionData.ProjectionMatrix;
}
return ProjectionMatrix;
}
Если у нас есть два соединённых портала, то при переходе из одного в другой нам необходимо обновлять оба в один Tick. В противном случае игрок телепортируется и окажется на другой стороне портала, но Target Portal не будет активен до следующего кадра/такта. Это создаст визуальные разрывы с материалом смещения меша плоскости, который мы видели выше.
void AExedrePortalManager::RequestTeleportByPortal( AExedrePortal* Portal, AActor* TargetToTeleport )
{
if( Portal != nullptr && TargetToTeleport != nullptr )
{
Portal->TeleportActor( TargetToTeleport );
//-----------------------------------
//Force update
//-----------------------------------
AExedrePortal* FuturePortal = UpdatePortalsInWorld();
if( FuturePortal != nullptr )
{
FuturePortal->ForceTick(); //Force update before the player render its view since he just teleported
UpdateCapture( FuturePortal );
}
}
}
Ну вот и всё, мы наконец закончили с Portal Manager!
Завершив Portal Manager, нам осталось только доделать сам актор Portal, после чего система заработает. Единственное, чего здесь не хватает — это функции Tick:
Вот как всё работает:
На скриншоте моего графа можно заметить два интересных момента: Is Point Inside Box и Get Portal Manager. Обе эти функции я пока не объяснял. Это статические функции, которые я определил в собственном классе, чтобы можно было вызывать их из любого места. Это своего рода вспомогательный класс. Ниже показан код этих функций, вы сами можете решить, куда их вставить. Если они не нужны вам за пределами системы порталов, то можно вставить их непосредственно в класс актора Portal
Поначалу я хотел использовать для определения нахождения внутри collision box актора портала систему коллизий, но она показалась мне недостаточно надёжной. Кроме того, мне кажется, что этот метод быстрее в использовании и имеет преимущество: он учитывает поворот актора.
bool IsPointInsideBox( FVector Point, UBoxComponent* Box )
{
if( Box != nullptr )
{
//From :
//https://stackoverflow.com/questions/52673935/check-if-3d-point-inside-a-box/52674010
FVector Center = Box->GetComponentLocation();
FVector Half = Box->GetScaledBoxExtent();
FVector DirectionX = Box->GetForwardVector();
FVector DirectionY = Box->GetRightVector();
FVector DirectionZ = Box->GetUpVector();
FVector Direction = Point - Center;
bool IsInside = FMath::Abs( FVector::DotProduct( Direction, DirectionX ) ) <= Half.X &&
FMath::Abs( FVector::DotProduct( Direction, DirectionY ) ) <= Half.Y &&
FMath::Abs( FVector::DotProduct( Direction, DirectionZ ) ) <= Half.Z;
return IsInside;
}
else
{
return false;
}
}
AExedrePortalManager* GetPortalManager( AActor* Context )
{
AExedrePortalManager* Manager = nullptr;
//Retrieve the World from the Context actor
if( Context != nullptr && Context->GetWorld() != nullptr )
{
//Find PlayerController
AExedrePlayerController* EPC = Cast<AExedrePlayerController>( Context->GetWorld()->GetFirstPlayerController() );
//Retrieve the Portal Manager
if( EPC != nullptr && EPC->GetPortalManager() != nullptr )
{
Manager = EPC->GetPortalManager();
}
}
return Manager;
}
Процесс приблизительно начинается тогда же, когда и функция Tick, но мы выполняем только первую часть последовательности, которая обновляет материал.
Почти.
Если реализовать систему порталов в таком виде, то скорее всего мы столкнёмся со следующей проблемой:
Что здесь происходит?
В этом gif частота кадров игры ограничена 6 FPS, чтобы показать проблему нагляднее. В одном кадре куб исчезает, потому что система отсечения Unreal Engine считает, что он должен быть невидимым.
Так получается, потому что обнаружение выполняется в текущем кадре, а затем используется в следующем. Это создаёт задержку в один кадр. Обычно это можно решить расширением bounding box объекта, чтобы он регистрировался ещё до того, как станет видимым. Однако здесь это не сработает, потому что при пересечении портала мы телепортируемся из одного места в совершенно другое.
Отключать систему отсечения тоже нельзя, особенно потому, что на уровнях со множеством объектов это снизит производительность. Кроме того, я попробовал многие команды движка Unreal, но не получил положительных результатов: во всех случаях сохранялась задержка в один кадр. К счастью, после подробного изучения исходного кода Unreal Engine мне удалось найти решение (путь был длинным — на это ушло больше недели)!
Как и в случае с компонентом SceneCapture, можно сообщить камере игрока, что мы совершили джамп-кат [6] — позиция камеры скакнула между двумя кадрами, а значит, мы не можем полагаться на информацию предыдущего кадра. Такое поведение можно наблюдать при использовании Matinee или Sequencer, например, при переключении камер: motion blur или сглаживание не могут при этом полагаться на информацию из предыдущего кадра.
Чтобы сделать это, нам нужно учитывать два аспекта:
Большое преимущество такого решения заключается в том, что вмешательство в процесс рендеринга движка минимально и его легко поддерживать в дальнейших обновлениях Unreal Engine.
#pragma once
#include "CoreMinimal.h"
#include "Engine/LocalPlayer.h"
#include "ExedreLocalPlayer.generated.h"
UCLASS()
class EXEDRE_API UExedreLocalPlayer : public ULocalPlayer
{
GENERATED_BODY()
UExedreLocalPlayer();
public:
FSceneView* CalcSceneView( class FSceneViewFamily* ViewFamily, FVector& OutViewLocation, FRotator& OutViewRotation, FViewport* Viewport, class FViewElementDrawer* ViewDrawer, EStereoscopicPass StereoPass) override;
void PerformCameraCut();
private:
bool bCameraCut;
};
Вот как всё реализуется:
#include "Exedre.h"
#include "ExedreLocalPlayer.h"
UExedreLocalPlayer::UExedreLocalPlayer()
{
bCameraCut = false;
}
FSceneView* UExedreLocalPlayer::CalcSceneView( class FSceneViewFamily* ViewFamily, FVector& OutViewLocation, FRotator& OutViewRotation, FViewport* Viewport, class FViewElementDrawer* ViewDrawer, EStereoscopicPass StereoPass)
{
// ULocalPlayer::CalcSceneView() use a ViewInitOptions to create
// a FSceneView which contains a "bCameraCut" variable
// See : H:GitHubUnrealEngineEngineSourceRuntimeRendererPrivateSceneCaptureRendering.cpp
// as well for bCameraCutThisFrame in USceneCaptureComponent2D
FSceneView* View = Super::CalcSceneView(ViewFamily,
OutViewLocation,
OutViewRotation,
Viewport,
ViewDrawer,
StereoPass );
if( bCameraCut )
{
View->bCameraCut = true;
bCameraCut = false;
}
return View;
}
void UExedreLocalPlayer::PerformCameraCut()
{
bCameraCut = true;
}
PerformCameraCut() просто запускает Camera Cut с помощью булева значения. Когда движок вызывает функцию CalcSceneView(), мы сначала запускаем исходную функцию. Затем проверяем, нужно выполнять склейку. Если это так, мы переопределяем булеву переменную Camera Cut внутри структуры FSceneView, которая будет использована процессом рендеринга движка, после чего сбрасываем значение булевой переменной (используем его).
UPROPERTY()
UExedreLocalPlayer* LocalPlayer;
Затем в функции BeginPlay():
LocalPlayer = Cast<UExedreLocalPlayer>( GetLocalPlayer() );
Также я добавил функцию для быстрого запуска Cut:
void AExedrePlayerController::PerformCameraCut()
{
if( LocalPlayer != nullptr )
{
LocalPlayer->PerformCameraCut();
}
}
void AExedrePortalManager::RequestTeleportByPortal( AExedrePortal* Portal, AActor* TargetToTeleport )
{
if( Portal != nullptr && TargetToTeleport != nullptr )
{
if( ControllerOwner != nullptr )
{
ControllerOwner->PerformCameraCut();
}
[...]
И на этом всё!
Camera Cut должна вызываться до обновления SceneCapture, именно поэтому она находится в начале функции.
Теперь мы научились думать порталами. [7]
Если система работает хорошо, то у нас должно получиться создавать вот такие вещи:
Если у вас возникли проблемы, то проверьте следующее:
Самые популярные вопросы, которые мне задавали об этом туториале:
Можно ли реализовать это на блюпринтах, а не через C++ ?
Основную часть кода можно реализовать в блюпринтах, за исключением двух аспектов:
Поэтому вам нужно или использовать реализацию на C++ для доступа к этим двум функциям, или изменить исходный код движка, чтобы сделать их доступными через блюпринты.
Можно ли использовать эту систему в VR ?
Да, по большей мере. Однако некоторые части придётся адаптировать, например:
Основной проблемой будет производительность, потому что другую сторону портала придётся рендерить дважды.
Может ли пересечь портал другой объект?
В моём коде нет. Однако сделать его более общим не так сложно. Для этого порталу необходимо отслеживать больше информации обо всех находящихся поблизости объектах, чтобы проверять, пересекают ли они его.
Поддерживает ли система рекурсию (портал внутри портала)?
В этом туториале нет. Для рекурсии понадобятся дополнительные render target и SceneCapture. Также будет необходимо определять, какой RenderTarget нужно рендерить первым, и так далее. Это довольно сложно и я не хотел этим заниматься, потому что для моего проекта это не нужно.
Можно ли пересечь портал возле стены?
К сожалению, нет. Однако я вижу два способа реализовать это (теоретически):
Автор: PatientZero
Источник [8]
Сайт-источник PVSM.RU: https://www.pvsm.ru
Путь до страницы источника: https://www.pvsm.ru/c-3/315537
Ссылки в тексте:
[1] кватернионов: https://en.wikipedia.org/wiki/Quaternion
[2] углами Эйлера: https://en.wikipedia.org/wiki/Euler_angles
[3] UTextureRenderTarget2D: http://api.unrealengine.com/INT/API/Runtime/Engine/Engine/UTextureRenderTarget2D/index.html
[4] ETextureRenderTargetFormat: http://api.unrealengine.com/INT/API/Runtime/Engine/Engine/ETextureRenderTargetFormat/index.html
[5] USceneCaptureComponent2D: http://api.unrealengine.com/INT/API/Runtime/Engine/Components/USceneCaptureComponent2D/index.html
[6] джамп-кат: https://en.wikipedia.org/wiki/Jump_cut
[7] Теперь мы научились думать порталами.: https://www.youtube.com/watch?v=TluRVBhmf8w
[8] Источник: https://habr.com/ru/post/448802/?utm_source=habrahabr&utm_medium=rss&utm_campaign=448802
Нажмите здесь для печати.