- PVSM.RU - https://www.pvsm.ru -

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

image

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

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

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

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

Thinking with Portals: создаём порталы в Unreal Engine 4 - 2

Thinking with Portals: создаём порталы в Unreal Engine 4 - 3

Antichamber (2013 год) и Portal (2007 год)

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

Prey, 2006 год

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

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

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

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

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

Thinking with Portals: создаём порталы в Unreal Engine 4 - 5

На этом изображении устройство захвата (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<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;
}


Здесь только функции 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:

Thinking with Portals: создаём порталы в Unreal Engine 4 - 6

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

Thinking with Portals: создаём порталы в Unreal Engine 4 - 7

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


Как вы вероятно заметили, для преобразования поворота/позиции я вызываю внешние функции. Они вызываются из пользовательского класса 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();
}

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

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

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

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

Thinking with Portals: создаём порталы в Unreal Engine 4 - 8

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

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

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

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

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

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

Thinking with Portals: создаём порталы в Unreal Engine 4 - 9

Thinking with Portals: создаём порталы в Unreal Engine 4 - 10

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

Thinking with Portals: создаём порталы в Unreal Engine 4 - 11

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

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

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

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

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

Thinking with Portals: создаём порталы в Unreal Engine 4 - 12

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

Thinking with Portals: создаём порталы в Unreal Engine 4 - 13

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

Thinking with Portals: создаём порталы в Unreal Engine 4 - 14

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

Thinking with Portals: создаём порталы в Unreal Engine 4 - 15


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

Thinking with Portals: создаём порталы в Unreal Engine 4 - 16

Thinking with Portals: создаём порталы в Unreal Engine 4 - 17

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

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

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

Thinking with Portals: создаём порталы в Unreal Engine 4 - 18

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

Thinking with Portals: создаём порталы в Unreal Engine 4 - 19

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

Thinking with Portals: создаём порталы в Unreal Engine 4 - 20

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


Вот функции 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<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();
}


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<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).

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


Далее мы рассмотрим функции обновления. Базовая функция довольно проста и вызывает более сложную. Перед вызовов функции 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( 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;
}


Настало время рассмотреть функцию 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();


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


Наконец, нам нужно реализовать вызов функции Teleport. Причина частичной обработки телепортации через Portal manager заключается в том, что необходимо гарантировать обновление нужных порталов, ведь только Manager имеет информацию о всех порталах в сцене.

Если у нас есть два соединённых портала, то при переходе из одного в другой нам необходимо обновлять оба в один 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:

Thinking with Portals: создаём порталы в Unreal Engine 4 - 21

Вот как всё работает:

  • Мы обновляем Material, чтобы он не оставался в активном состоянии.
  • Если в настоящий момент портал неактивен, то оставшаяся часть такта отбрасывается.
  • Мы получаем класс Character для доступа к Camera Location.
  • Первая часть проверяет, находится ли камера в collision box портала. Если да, то мы смещаем меш портала с помощью его Material.
  • Вторая часть — это повторная проверка нахождения внутри collision box. Если она выполняется, то мы вызываем функции, проверяющую, пересекаем ли мы портал.
  • Если мы и в самом деле его пересекаем, то получаем Portal manager, а затем вызываем функцию Teleport.

На скриншоте моего графа можно заметить два интересных момента: 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;
}


Последняя часть актора Blueprint — это ForceTick. Помните, что Force Tick вызывается, когда игрок пересекает портал и оказывается рядом с другим порталом, для которого Portal Manager принудительно выполняет обновление. Так как мы только телепортировались, использовать тот же код необязательно, и можно задействовать его упрощённую версию:

Thinking with Portals: создаём порталы в Unreal Engine 4 - 22

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

Мы закончили?

Почти.

Если реализовать систему порталов в таком виде, то скорее всего мы столкнёмся со следующей проблемой:

Thinking with Portals: создаём порталы в Unreal Engine 4 - 23

Что здесь происходит?

В этом gif частота кадров игры ограничена 6 FPS, чтобы показать проблему нагляднее. В одном кадре куб исчезает, потому что система отсечения Unreal Engine считает, что он должен быть невидимым.

Так получается, потому что обнаружение выполняется в текущем кадре, а затем используется в следующем. Это создаёт задержку в один кадр. Обычно это можно решить расширением bounding box объекта, чтобы он регистрировался ещё до того, как станет видимым. Однако здесь это не сработает, потому что при пересечении портала мы телепортируемся из одного места в совершенно другое.

Отключать систему отсечения тоже нельзя, особенно потому, что на уровнях со множеством объектов это снизит производительность. Кроме того, я попробовал многие команды движка Unreal, но не получил положительных результатов: во всех случаях сохранялась задержка в один кадр. К счастью, после подробного изучения исходного кода Unreal Engine мне удалось найти решение (путь был длинным — на это ушло больше недели)!

Как и в случае с компонентом SceneCapture, можно сообщить камере игрока, что мы совершили джамп-кат [6] — позиция камеры скакнула между двумя кадрами, а значит, мы не можем полагаться на информацию предыдущего кадра. Такое поведение можно наблюдать при использовании Matinee или Sequencer, например, при переключении камер: motion blur или сглаживание не могут при этом полагаться на информацию из предыдущего кадра.

Чтобы сделать это, нам нужно учитывать два аспекта:

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

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


Давайте начнём с создания нового класса, унаследованного от LocalPlayer. Ниже представлен заголовок, в котором указаны два основных компонента: переопределение вычислений Scene Viewport и новая функция для вызова склейки камеры.

#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, которая будет использована процессом рендеринга движка, после чего сбрасываем значение булевой переменной (используем его).


Со стороны Player Controller изменения минимальны. Нужно добавить в заголовок переменную для хранения ссылки на собственный класс LocalPlayer:

		UPROPERTY()
		UExedreLocalPlayer*	LocalPlayer;

Затем в функции BeginPlay():

	LocalPlayer = Cast<UExedreLocalPlayer>( GetLocalPlayer() );

Также я добавил функцию для быстрого запуска Cut:

void AExedrePlayerController::PerformCameraCut()
{
	if( LocalPlayer != nullptr )
	{
		LocalPlayer->PerformCameraCut();
	}
}


Наконец, в функции Portal Manager RequestTeleportByPortal() мы можем выполнить во время телепортации Camera Cut:

void AExedrePortalManager::RequestTeleportByPortal( AExedrePortal* Portal, AActor* TargetToTeleport )
{
	if( Portal != nullptr && TargetToTeleport != nullptr )
	{
		if( ControllerOwner != nullptr )
		{
			ControllerOwner->PerformCameraCut();
		}
[...]

И на этом всё!

Camera Cut должна вызываться до обновления SceneCapture, именно поэтому она находится в начале функции.

Окончательный результат

Теперь мы научились думать порталами. [7]

Если система работает хорошо, то у нас должно получиться создавать вот такие вещи:

Thinking with Portals: создаём порталы в Unreal Engine 4 - 24

Если у вас возникли проблемы, то проверьте следующее:

  • Убедитесь, что Portal Manager правильно создан и инициализирован.
  • Render target создан правильно (можно для начала использовать созданный в content browser).
  • Порталы правильно активируются и деактивируются.
  • У порталов правильно задан в редакторе актор Target.

Вопросы и ответы

Самые популярные вопросы, которые мне задавали об этом туториале:

Можно ли реализовать это на блюпринтах, а не через C++ ?

Основную часть кода можно реализовать в блюпринтах, за исключением двух аспектов:

  • Функция LocalPlayer GetProjectionData(), используемая для получения матрицы проецирования, недоступна в блюпринтах.
  • Функция LocalPlayer CalcSceneView(), критически важная для решения проблемы с системой отсечения, недоступна в блюпринтах.

Поэтому вам нужно или использовать реализацию на C++ для доступа к этим двум функциям, или изменить исходный код движка, чтобы сделать их доступными через блюпринты.

Можно ли использовать эту систему в VR ?

Да, по большей мере. Однако некоторые части придётся адаптировать, например:

  • Нужно использовать два Render Targets (по одному для каждого глаза) и маскировать их в материале портала для отображения рядом в экранном пространстве. Каждый render target должен иметь половинную ширину от разрешения VR-устройства.
  • Нужно использовать два SceneCapture для render target с правильным расстоянием (расстояние между глазами) для создания стереоскопических эффектов.

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

Может ли пересечь портал другой объект?

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

Поддерживает ли система рекурсию (портал внутри портала)?

В этом туториале нет. Для рекурсии понадобятся дополнительные render target и SceneCapture. Также будет необходимо определять, какой RenderTarget нужно рендерить первым, и так далее. Это довольно сложно и я не хотел этим заниматься, потому что для моего проекта это не нужно.

Можно ли пересечь портал возле стены?

К сожалению, нет. Однако я вижу два способа реализовать это (теоретически):

  • Отключить коллизии игрока, чтобы он мог проходить сквозь стены. Легко реализовать, но это приведёт ко множеству побочных эффектов.
  • Взломать систему коллизий, чтобы создавать дыру динамически, что позволит игроку пройти. Для этого потребуется модифицировать физическую систему движка. Однако из того, что я знаю, после загрузки уровня статическую физику обновлять нельзя. Поэтому для поддержки этой функции потребуется довольно много работы. Если ваши порталы статичны, то, вероятно, можно обойти эту проблему, используя level streaming для переключения между разными коллизиями.

Автор: 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