[Перевод] Thinking with Portals: создаём порталы в Unreal Engine 4

image


В этой статье я расскажу, как создавать порталы в Unreal Engine 4. Я не нашёл никаких источников, подробно описывающих такую систему (наблюдение сквозь порталы и проход через них), поэтому решил написать собственную.

Что такое портал?


Давайте начнём с примеров и объяснения того, что такое портал. Проще всего описать порталы как способ прохода из одного пространство в другое. В некоторых популярных играх эта концепция используется для визуальных эффектов и даже для геймплейных механик:

Примеры порталов в играх (GIF)
611020f86b634e1946a79f86ee8c4024.gif

77d7b9813811fc5b1748d8d29c7599ad.gif

Antichamber (2013 год) и Portal (2007 год)
527671ad1b23838f56c324d350bf2e1e.gif

Prey, 2006 год


Из трёх игр самой известной, вероятно, является Portal, однако лично меня всегда восхищала Prey и именно её я мечтал скопировать. Однажды я попробовал реализовать собственную версию в Unreal Engine 4, но не особо преуспел, потому что в движке не хватало функционала. Тем не менее, мне удалось провести вот такие эксперименты:

Your browser does not support HTML5 video.


Однако только в новых версиях Unreal Engine мне наконец-то удалось добиться нужного эффекта:


Порталы — как они работают?


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

По сути, портал — это окно, которое выходит не наружу, а в другое место, то есть мы локально задаём определённую точку обзора относительно объекта и реплицируем эту точку обзора где-то ещё. Пользуясь этим принципом, мы можем соединить два пространства, даже если они находятся очень далеко друг от друга. Окно напоминает маску, которая позволяет нам узнать, где и когда отображать другое пространство вместо исходного. Так как исходная точка обзора реплицируется в другое место, это даёт нам иллюзию непрерывности.

6b3d5684bf30d66c8b60d551049f7a9b.png


На этом изображении устройство захвата (SceneCapture в UE4) расположено перед пространством, которое соответствует пространству, видимому с точки зрения игрока. Всё, что видимо после линии, заменяется тем, что может видеть захват. Так как устройство захвата может быть расположено между дверью и другими объектами, важно использовать так называемую «плоскость отсечения» (clipping plane). В случае портала мы хотим, чтобы близкая плоскость отсечения маскировала объекты, видимые перед порталом.

Подведём итог. Нам нужны:

  • Местоположение игрока
  • Точка входа в портал
  • Точка выхода из портала
  • Устройство захвата с плоскостью отсечения


Как реализовать это в Unreal Engine?

Я построил свою систему на основании двух основных классов, управляемых PlayerController и Character. Класс Portal — это истинная точка входа в портал, точкой обзора/выхода которого является актор Target. Также здесь есть Portal Manager, который порождается PlayerController и обновляется Character для управления каждым порталом на уровне и их обновлением, а также для манипулирования объектом SceneCapture (который является общим для всех порталов).

Учтите, что в туториале ожидается, что у вас есть доступ к классам Character и PlayerController из кода. В моём случае они называются ExedreCharacter и ExedrePlayerController.


Создание класса актора портала


Давайте начнём с актора портала, который будет использоваться для задания «окон», через которые мы будем смотреть на уровень. Задача актора — предоставление информации относительно игрока для вычисления различных позиций и поворотов. Также он будет заниматься распознаванием того, пересекает ли игрок портал, и его телепортацией.

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

  • Для удобного отказа от вычислений у портала есть состояние «активен-не активен». Это состояние обновляется Portal Manager.
  • Портал имеет переднюю и заднюю стороны, определяемые его позицией и направлением (вектором forward).
  • Чтобы узнать, пересекает ли игрок портал, он хранит предыдущее положение игрока и сравнивает его с текущим. Если в предыдущем такте игрок находился перед порталом, а в текущем — за ним, то мы считаем, что игрок его пересёк. Обратное поведение игнорируется.
  • У портала есть ограничивающий объём, чтобы не выполнять вычислений и проверок, пока игрок не находится в этом объёме. Пример: игнорировать пересечение, если игрок на самом деле не касается портала.
  • Местоположение игрока вычисляется из местоположения камеры, чтобы обеспечить правильное поведение в случае, когда точка обзора пересекает портал, но не тело игрока.
  • Портал получает Render Target, который отображает в каждом такте другую точку обзора на случай, если текстура в следующий раз будет неверной и потребует замены.
  • Портал хранит ссылку на другой актор, имеющий название Target, чтобы знать, где находится другое пространство, с которым нужно связаться.


Воспользовавшись этими правилами, я создал в качестве начальной точки новый класс 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).
Конструктор здесь занимается подготовкой корневых компонентов. Я решил создавать два корневых компонента, потому что актор портала будет сочетать в себе и графические эффекты, и коллизии/распознавание. Поэтому мне нужен был простой способ для определения того, где находится плоскость окна/портала, без необходимости использования функций блюпринтов или других трюков. PortalRootComponent будет в дальнейшем основой для всех вычислений, связанных с порталом.

Root портала задан как динамический, на случай, если класс Blueprint анимирует его (например, использует анимацию открытия/закрытия).

// Sets default values
AExedrePortal::AExedrePortal(const FObjectInitializer& ObjectInitializer)
	: Super(ObjectInitializer)
{
	PrimaryActorTick.bCanEverTick 	= true;
	bIsActive 						= false;

	RootComponent = CreateDefaultSubobject(TEXT("RootComponent"));
	RootComponent->Mobility = EComponentMobility::Static;

	PortalRootComponent	= CreateDefaultSubobject(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;
}



Здесь только функции Get и Set, и ничего больше. Состоянием активности мы будем управлять из другого места.

bool AExedrePortal::IsActive()
{
	return bIsActive;
}

void AExedrePortal::SetActive( bool NewActive )
{
	bIsActive = NewActive;
}



События блюпринта, в классе C++ я ничего не делаю.

void AExedrePortal::ClearRTT_Implementation()
{

}

void AExedrePortal::SetRTT_Implementation( UTexture* RenderTexture )
{

}

void AExedrePortal::ForceTick_Implementation()
{

}



Функции Get и Set для актора Target. В этой части тоже больше нет ничего сложного.

AActor* AExedrePortal::GetTarget()
{
	return Target;
}

void AExedrePortal::SetTarget( AActor* NewTarget )
{
	Target = NewTarget;
}



С помощью этой функции мы можем легко проверять, находится ли точка перед плоскостью, а в нашем случае это портал. В функции для выполнения вычислений используется структура FPlane движка UE4.

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:

185809d4fd81675cdd6f1cc871eaf193.gif


Как видите, при пересечении портала камера поворачивается относительно своего вектора forward (вращается). Так получается, потому что начальная и конечная точка параллельны разным плоскостям:

07297fb30171a80c1cc0d831701feb74.jpg


Поэтому чтобы всё сработало, нам нужно преобразовать движение игрока в относительное пространство портала, чтобы преобразовать его в пространство 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( 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;
}



Как вы вероятно заметили, для преобразования поворота/позиции я вызываю внешние функции. Они вызываются из пользовательского класса UTool, в котором заданы статические функции, которые можно вызывать из любого места (в том числе и из блюпринтов). Ниже показан их код, вы можете реализовать их так, как вам кажется лучше (вероятно, проще всег опоместить их в класс актора Portal).

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();
}


Преобразование поворота реализовать было чуть труднее. В конце концов наилучшим решением оказалось использование кватернионов, потому что это получается намного точнее, чем работа с обычными углами Эйлера, и требует всего нескольких строк кода. Повороты кватернионами выполняются с помощью умножения, поэтому в нашем случае применив Inverse () к тому повороту, который мы хотим преобразовать, мы переместим его в локальное пространство. Далее нам просто достаточно снова умножить его на поворот Target, чтобы получить окончательный поворот.

Создание меша портала


Чтобы выглядеть красиво с точки зрения игрока, моя система порталов использует определённый меш. Меш разделён на две разные плоскости:

  • Плоскость 1: основная плоскость, на которой отображается render target портала. Эта плоскость обладает довольно необычным поведением, потому что её задача — немного отталкиваться от игрока при его приближении, чтобы избежать отсечения камерой. Так как границы плоскости не движутся, а перемещаются только её средние вершины, это позволяет игроку накладываться на рендеринг портала без визуальных артефактов. Грани на краях имеют собственные UV в верхней половине, в то время как внутренние грани имеют свои UV в нижней половине, что позволяет с лёгкостью замаскировать их в шейдере.
  • Плоскость 2: эта плоскость используется только для того, чтобы расширить стандартный ограничивающий параллелепипед (Bounding Box) меша. Нормали вершин направлены вниз, поэтому даже на неплоской земле меш по умолчанию виден не будет (потому что материал рендеринга не будет двусторонним).


72e125f6fd315caa2777f65dc0eaf39a.gif


Зачем же использовать меш таким образом?

Я решил, что «плоскость 1» будет растягиваться при приближении игрока. Это позволяет игроку накладываться на портал и проходить по нему без его усечения (вырезания). Это может произойти, например, если камера пока не пересекла плоскость портала, но её уже коснулись ноги игрока. Это позволяет не заниматься отсеканием игрока и дублированием меша с другой стороны.

Задача «плоскости 2» — расширение стандартного bounding box меша. Так как «плоскость 1» является плоской, bounding box по одной оси имеет толщину 0, и если камера оказывается за ним, то движок её отсечёт (т.е. не будет её рендерить). Плоскость 1 имеет размер 128×128, поэтому её можно легко масштабировать средствами движка. Плоскость 2 немного больше и находится ниже пола (ниже 0).

Создав меш, мы просто экспортируем его из стороннего 3D-редактора и импортируем в Unreal. Он будет использоваться на следующем этапе.

Создание материала портала


Для отображения другой стороны портала нам нужно создать собственный материал. Создадим новый материал в браузере контента (content browser) (я назвал его MAT_PortalBase):

7b0402a9cd6c6d62438e06fa3a7cd1f4.png


dea96ca1b613a92acc7ca35e0bbe44b1.png


Теперь откроем его и создадим следующий граф:

cfe4fac175e5e399c9e615a6afd0b800.jpg


Вот как работает материал:

  • FadeColor — это цвет, который будет видим через портал, когда он находится очень далеко. Он нужен, потому что мы не рендерим все порталы всегда, поэтому мы затеняем рендеринг, когда игрок/камера находится далеко.
  • Чтобы узнать, как далеко игрок от портала, я определяю расстояние между Camera Position и Actor Position. Затем я делю расстояние на максимальное значение, с которым хочу выполнить сравнение. Например, если заданный мной максимум равен 2000, а расстояние до игрока равно 1000, то мы получим 0.5. Если игрок дальше, то я получу значение больше 1, поэтому я использую для его ограничения нод насыщенности (saturate). Далее идёт узел Smoothstep, используемый для изменения масштаба расстояния как градиента и более точного управления затенением портала. Например, я хочу, чтобы когда игрок находится близко, затенение полностью пропадало.
  • Я использую вычисление расстояния как значение альфа-канала для узла Lerp, чтобы смешать цвет затенения и текстуру, которая будет render target портала.
  • Наконец, я изолирую компонент Y UV-координат, чтобы создать маску, позволяющую узнать, какие вершины меша будут оттолкнуты. Я умножаю эту маску на нужную мне величину отталкивания. Я использую отрицательное значение, чтобы при умножении на нод нормалей вершин они перемещались в обратном направлении.


Сделав всё это, мы создали готовый к использованию материал.

Создание актора портала в блюпринте


Давайте настроим новый класс блюпринта, наследующий от актора Portal. Нажмите правой клавишей на content browser и выберите класс Blueprint:

ead28b5a23369c2c86a076ec8bb1dda0.png


Теперь введите в поле поиска «portal», чтобы выбрать класс портала:

bd7da4555cff2dc953ab3b0b0f4d1057.png


Откройте блюпринт, если он ещё не открыт. В списке компонентов вы увидите следующую иерархию:

73c34c308a70f644759dc9057b0facd2.png


Как мы и ожидали, здесь есть root component и portal root. Давайте добавим в PortalRootComponent компонент статичного меша и загрузим в него меш, созданный на предыдущем этапе:

fa4ef311d11c803afc565060a9902c24.jpg



Также добавим Collision Box, который будет использоваться для определения того, находится ли игрок внутри объёма портала:

0b028fdf696598f6b3eca9af3049caa1.png


c2a85d747c8b76900cae32e9f9cc1a59.png


Collision box находится ниже компонента сцены, связанного с основным root, а не под Portal root. Также я добавил значок (биллборд) и компонент стрелки, чтобы портал был заметнее на уровнях. Разумеется, делать это необязательно.

Теперь давайте настроим материал в блюпринте.

Для начала нам нужны две переменные — одна будет иметь тип Actor и название PortalTarget, вторая имеет тип Dynamic Material Instance и называется MaterialInstance. PortalTarget будет ссылкой на позицию, в которое смотрит окно портала (поэтому переменная общая, со значком открытого глаза), чтобы мы могли изменять её, когда актор будет размещён на уровне. MaterialInstance будет хранить ссылку на динамический материал, чтобы в дальнейшем мы могли назначать render target портала на лету.

4740b9772dfc7ce30cda63300f1682a5.png


Также нам нужно добавить собственные ноды событий. Лучше всего открыть меню правой клавиши мыши в Event Graph и найти названия событий:

bc2719d224381581c9bdb8ea5ce7c365.png


И здесь создать следующую схему:

0f440024f5c322c6640609bb8d20bcad.jpg


  • Begin Play: здесь мы вызваем родительскую функцию SetTarget () портала, чтобы назначить ей ссылку на актора, который позже будет использоваться для SceneCapture. Затем мы создаём новый Dynamic Material и назначаем ему значение переменной MaterialInstance. С этим новым материалом мы можем назначить его компоненту Static Mesh Component. Также я задал материалу текстуру-пустышку, но это делать необязательно.
  • Clear RTT: цель этой функции — очистка текстуры Render Target, назначенной материалу портала. Она запускается Portal manager.
  • Set RTT: цель этой функции — задание материала render target портала. Она запускается Portal manager.


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

Portal Manager


Итак, теперь у нас есть все базовые элементы, которые необходимы для создания нового класса, наследуемого от AActor, который будет являться Portal Manager. Возможно, в вашем проекте класс Portal Manager будет не нужен, но в моём случае он намного упрощает работу с некоторыми аспектами. Вот список задач, выполняемых Portal manager:

  • Portal manager — это актор, создаваемый Player Controller и прикреплённый к нему для отслеживания состояния и эволюции игрока внутри уровня игры.
  • Создание и уничтожение render target портала. Идея заключается в динамическом создании текстуры render target, соответствующей разрешению экрана игрока. Кроме того, при изменении разрешения в процессе игры менеджер будет автоматически преобразовывать её в нужный размер.
  • Portal manager находит и обновляет на уровне акторов Portal, чтобы дать им render target. Эта задача выполняется таким образом для того, чтобы обеспечивать совместимость с level streaming. При появлении нового актора он должен получить текстуру. Кроме того, в случае изменения Render target менеджер тоже может назначить новый автоматически. Так системой управлять проще, вместо того, чтобы каждый актор Portal вручную обращался к менеджеру.
  • Компонент SceneCapture прикреплён к Portal manager, чтобы не создавать по одной копии для каждого портала. Кроме того, это позволяет заново использовать его каждый раз, когда мы переключаемся на конкретный актор портала на уровне.
  • Когда портал решает телепортировать игрока, он отправляет запрос Portal Manager. Это нужно для того, чтобы обновлять и исходный, и конечный (если он есть) порталы, чтобы переход происходил без стыков.
  • Обновление Portal manager происходит в конце функции Character tick (), чтобы всё обновлялось правильно, в том числе и камера игрока. Это обеспечивает синхронизацию всего, что находится на экране, и позволяет избежать задержки на один кадр в процессе рендеринга движком.


Давайте взглянем на заголовок 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;
};



Прежде чем углубляться в подробности, я покажу, как актор создаётся из класса Player Controller, вызываемого из функции BeginPlay ():

	FActorSpawnParameters SpawnParams;

	PortalManager = nullptr;
	PortalManager = GetWorld()->SpawnActor(	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( 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;
}



Вот функции get/set Portal Manager (после этого мы перейдём к более интересным вещам):

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;
	}
}



Очевидно, что в первую очередь стоит начать с функции Init ().

Основная задача этой функции — создание компонента SceneCapture (то есть упомянутого выше устройства захвата) и его правильная настройка. Она начинается с создания нового объекта и регистрации его как компонента данного актора. Затем мы переходим к заданию свойств, относящихся к этому захвату.

Свойства, которые нужно упомянуть:

  • bCaptureEveryFrame = false: мы не хотим, чтобы захват включался тогда, когда нам это не нужно. Мы будем управлять им вручную.
  • bEnableClipPlane = true: довольно важное свойство для правильного рендеринга захвата портала.
  • bUseCustomProjectionMatrix = true: это позволяет нам заменить проекцию Capture собственной, основанной на точке обзора игрока.
  • CaptureSource = ESceneCaptureSource: SCS_SceneColorSceneDepth: этот режим немного затратен, но необходим для рендеринга достаточного объёма информации.


Остальные свойства в основном связаны с параметрами постобработки. Они являются удобным способом управлять качеством, а значит и производительностью захвата.

Последняя часть вызывает функцию, которая создаёт Render Target, который мы увидим ниже.

void AExedrePortalManager::Init()
{
	//------------------------------------------------
	//Create Camera
	//------------------------------------------------
	SceneCapture = NewObject(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();
}



GeneratePortalTexture () — это функция, вызываемая при необходимости, когда нужно создать новую текстуру Render Target для порталов. Это происходит в функции инициализации, но она также может быть вызвана во время обновления Portal Manager. Именно поэтому в этой функции есть внутренняя проверка смены разрешения окна просмотра. Если оно не произошло, то обновление не выполняется.

В своём случае я создал класс-обёртку для 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(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).

Более подробную информацию см. в следующих источниках:



Далее мы рассмотрим функции обновления. Базовая функция довольно проста и вызывает более сложную. Перед вызовов функции GeneratePortalTexture () существует задержка, чтобы избежать воссоздания render target при изменении размера viewport (например в редакторе). Во время публикации игры эту задержку можно убрать.

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.
Вот как работает обновление мира внутри UpdatePortalsInWorld ():

  1. Мы получаем информацию об игроке (его позицию и позицию камеры)
  2. Создаём цикл iterator, чтобы найти все акторы порталов внутри текущего мира
  3. В цикле обрабатываем каждый портал, один за другим, для запуска события ClearRTT (), а затем его отключения. Также мы получаем дополнительную информацию (например, нормаль к порталу).
  4. Мы проверяем, является ли этот портал ближайшим к игроку, и если это так, то мы ссылаемся на него, чтобы вернуться к нему позже.


Проверка, определяющая правильность портала, проста: мы отдаём приоритет ближайшему к игроку порталу, потому что он скорее всего будет самым видимым с его точки обзора. Для отбрасывания близких, но, например, находящихся за игроком порталов, потребуются более сложные проверки, но я не хотел сосредотачиваться на этом в своём туториале, потому что это может стать довольно трудным.

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( TActorIteratorActorItr( 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;
}



Настало время рассмотреть функцию UpdateCapture ().

Это функция обновления, захватывающая другую сторону портала. Из комментариев всё должно быть понятно, но вот краткое описание:

  1. Мы получаем ссылки на Character и Player Controller.
  2. Мы проверяем, всё ли правильно (Portal, компонент SceneCapture, Player).
  3. Мы получаем Camera от игрока и Target от портала.
  4. Преобразуем позицию и поворот игрока, чтобы применить их к SceneCapture.
  5. Также мы обновляем плоскость отсечения SceneCapture на основании информации от Target.
  6. Теперь, когда SceneCapure находится там, где нужно, мы можем активировать портал.
  7. Назначаем Render Target и SceneCapture, и порталу.
  8. Обновляем матрицу проецирования из PlayerController.
  9. Наконец, мы запускаем функцию Capture компонента SceneCapture для выполнения самого рендеринга сцены.


Как мы видим, при телепортации игрока ключевым элементом естественного и безупречного поведения SceneCapture является правильное преобразование позиции и поворота портала в локальное пространство Target.

Определение ConvertLocationToActorSpace () см. в разделе «Телепортируем актора».
void AExedrePortalManager::UpdateCapture( AExedrePortal* Portal )
{
	if( ControllerOwner == nullptr )
	{
		return;
	}

	AExedreCharacter* Character = ControllerOwner->GetCharacter()
    
            

© Habrahabr.ru