Unreal Engine 5
September 25

Лента Мёбиуса часть 2.

В прошлый раз я рассказал, как собрал процедурный меш в форме Ленты Мебиуса для своего pet проекта. В этот раз разберемся, как менять гравитацию и адаптировать контроллер под случайную гравитацию.

ICRGravityController

Очевидно, раз мой пет проект начинает заигрывать с гравитацией, то на одной ленте Мебиуса останавливаться я не буду, поэтому надо организовывать все так, чтобы я мог делать какие угодно локальные изменения гравитации. Лучший подход для этого - создать интерфейс. Это позволит мне выбрать любой подход к изменению гравитации игрока в будущем: и если Лента Мебиуса это Actor, то в дальнейшем за изменение гравитации может отвечать, например, компонент.

// This class does not need to be modified.
UINTERFACE(BlueprintType)
class UCRGravityController : public UInterface
{
	GENERATED_BODY()
};

/**
 * 
 */
class FPSGAME_API ICRGravityController
{
	GENERATED_BODY()

	// Add interface functions to this class. This is the class that will be inherited to implement this interface.
public:

	virtual void GetGravityAndOrientation(FVector WorldLocation, FVector& Gravity, FVector& Orientation) {};
};

Я специально разделил направление гравитации и ориентацию для ситуаций, когда игрок сошел с нашей ленты в сторону, и мы хотим вернуть его на ленту, но не поворачивать его ориентацию. В общем же случае гравитация и ориентация совпадают с нормалью поверхности нашей ленты Мебиуса. Здесь и далее под ориентацией я понимаю вектор “вверх” капсулы игрока (в каком то смысле Pitch). Так как мой пет проект - это FPS, то остальные вращения либо не важны (Roll), либо управляются игроком (Yaw)

Теперь надо слегка модифицировать наш класс ленты.

Первым делом добавим реализацию нашего интерфейса:

class FPSGAME_API ACRMobiusStrip : public AActor, public ICRGravityController
.
.
.
virtual void GetGravityAndOrientation(FVector WorldLocation, FVector& Gravity, FVector& Orientation) override;

Также добавим еще один сплайн, в котором для удобства будем хранить все точки и ориентации, которые мы использовали для генерации

	UPROPERTY(VisibleAnywhere,BlueprintReadWrite)
	class USplineComponent* MobiusSplineComponent
	FSplinePoint Point(index, Location, ESplinePointType::Curve, Rotator);
	//setup additional spline for storing Rotation
	MobiusSplineComponent->AddPoint(Point);

Считаем гравитацию

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

const FVector LocationOnSpline = MobiusSplineComponent->FindLocationClosestToWorldLocation(WorldLocation, ESplineCoordinateSpace::Type::World);

const float Key = MobiusSplineComponent->FindInputKeyClosestToWorldLocation(WorldLocation);
	
const FQuat QuatAtStart = MobiusSplineComponent->GetRotationAtSplineInputKey(FMath::Floor(Key),ESplineCoordinateSpace::Type::World).Quaternion();
FQuat QuatAtEnd = MobiusSplineComponent->GetRotationAtSplineInputKey(FMath::CeilToFloat(Key),ESplineCoordinateSpace::Type::World).Quaternion();

Дальше надо учесть, что наша ориентация ведет себя не гладко, и в точке соединения начала и конца ленты у нас разрыв. Учтём его.

if ( NumberOfHalfTurns % 2 != 0 && FMath::CeilToFloat(Key) == MobiusSplineComponent->GetNumberOfSplinePoints())
{
	QuatAtEnd = QuatAtEnd * FQuat(FRotator(0.0f, 0.0f , -180.f));
}

Получаем итоговое значение ориентации в данной точке сплайна:

	const FQuat Rotation = FQuat::Slerp(QuatAtStart, QuatAtEnd, FMath::Frac(Key));

N.B. Я намеренно использую Кватернионы, а не ротаторы, т.к. поскольку мы крутимся достаточно рандомно и непредсказуемо, шансы получить шарнирный замок (gimbal lock) очень высоки, поэтому лучше использовать Кватернионы.

Сохраним расстояние от оси нашей ленты:

FVector Distance = WorldLocation - LocationOnSpline;
const float WidthOffset = Rotation.GetAxisY().Dot(Distance);

Теперь у нас 3 варианта: мы находимся “над”(где под вертикалью мы понимаем ориентацию ленты)  плоскостью нашей ленты, сбоку от ней, или в области ребра.

Разберем все 3 случая отдельно.

Мы над лентой

Этому соответствует условие

FMath::Abs(WidthOffset) < SegmentWidth/2

Тут все просто: если направление до нас совпадает с направлением нормали, то гравитация противоположна направлению нормали (тянет нас к ленте). Если мы находимся под лентой, то гравитация должна совпадать с нормалью (опять же  тянуть нас к ленте). Ориентация капсулы игрока противоположна направлению гравитации:

FVector MobiusUp = Rotation.GetAxisZ();
if ((Distance).GetSafeNormal().Dot(MobiusUp) < 0.0f)
{
	MobiusUp = MobiusUp * -1.0f;
}	
		
Orientation =  MobiusUp;
Gravity = MobiusUp * -1.0f;

Мы сбоку от ленты

Этому соответствует условие

FMath::Abs(WidthOffset) > SegmentWidth/2

Для начала поймем, что мы не на грани.

const float HeightOffset = Rotation.GetAxisZ().Dot(Distance);
	
if (FMath::Abs(HeightOffset) > SegmentDepth/2)
{

Мы хотим, чтобы игрок вернулся на ленту, но при этом не хотим крутить ему ориентацию, так как такое возвращение будет скорее небольшим улучшением UX, а не полноценной механикой.

Тянем его к точке на поверхности, на которой он может стоять. Ориентация соответствует предыдущему случаю.

const FVector FloorPoint = LocationOnSpline + Distance.ProjectOnToNormal(Rotation.GetAxisZ()).GetSafeNormal() * SegmentDepth/2;
FVector ToFloorPointDirection = Gravity = (FloorPoint - WorldLocation).GetSafeNormal();
FVector MobiusUp = Rotation.GetAxisZ();
if ((Distance).GetSafeNormal().Dot(MobiusUp) < 0.0f)
{
	MobiusUp = MobiusUp * -1.0f;
}
Orientation =  MobiusUp;
Gravity = ToFloorPointDirection;

Мы на грани

Условия:

FMath::Abs(WidthOffset) > SegmentWidth/2
FMath::Abs(HeightOffset) < SegmentDepth/2

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

Для начала посчитаем “вверх” относительно поверхности ленты и относительно  грани:

FVector MobiusUp = Rotation.GetAxisZ();
if ((Distance).GetSafeNormal().Dot(MobiusUp) < 0.0f)
{
	MobiusUp = MobiusUp * -1.0f;
}
			
FVector SideUp = Rotation.GetAxisY();		
		
if (WidthOffset < 0.0f)
{
	SideUp = SideUp * -1.0f;
}

Теперь в зависимости от положения по “вертикали”, получим значение, насколько глубоко игрок зашел “в грань”

	const float LerpAlpha = 1.0f - FMath::Abs(HeightOffset / (SegmentDepth/2));

Теперь интерполируем между двумя векторами получая вектор “вверх” для игрока

Orientation = FVector::SlerpVectorToDirection(MobiusUp, SideUp, LerpAlpha);		

Для гравитации сделаем проще: пытаемся вернуть игрока туда откуда он прошел на грань, плюс в направлении к ленте - это позволит ему поймать момент и остаться на грани

Gravity = MobiusUp;
FVector SideDown = SideUp * -1.0f;
Gravity = FVector::SlerpVectorToDirection(Gravity, SideDown, LerpAlpha);		

В  третьей части настроим наш контролер и персонажа, чтобы он мог двигаться по ленте.

P.S.  Финальный код функции вычисления гравитации:

void ACRMobiusStrip::GetGravityAndOrientation(FVector WorldLocation, FVector& Gravity, FVector& Orientation)
{
	const FVector LocationOnSpline = MobiusSplineComponent->FindLocationClosestToWorldLocation(WorldLocation, ESplineCoordinateSpace::Type::World);

	const float Key = MobiusSplineComponent->FindInputKeyClosestToWorldLocation(WorldLocation);
	
	const FQuat QuatAtStart = MobiusSplineComponent->GetRotationAtSplineInputKey(FMath::Floor(Key),ESplineCoordinateSpace::Type::World).Quaternion();
	FQuat QuatAtEnd = MobiusSplineComponent->GetRotationAtSplineInputKey(FMath::CeilToFloat(Key),ESplineCoordinateSpace::Type::World).Quaternion();

	if ( NumberOfHalfTurns % 2 != 0 && FMath::CeilToFloat(Key) == MobiusSplineComponent->GetNumberOfSplinePoints())
	{
		QuatAtEnd = QuatAtEnd * FQuat(FRotator(0.0f, 0.0f , -180.f));
	}

	const FQuat Rotation = FQuat::Slerp(QuatAtStart, QuatAtEnd, FMath::Frac(Key));
	FVector Distance = WorldLocation - LocationOnSpline;
	const float WidthOffset = Rotation.GetAxisY().Dot(Distance);
	if (FMath::Abs(WidthOffset) > SegmentWidth/2)
	{
		//we outside spline
		const float HeightOffset = Rotation.GetAxisZ().Dot(Distance);
	
		if (FMath::Abs(HeightOffset) > SegmentDepth/2)
		{		
			//we not inside spline depth		
			//DrawDebugSphere(GetWorld(), FloorPoint, 100.0f, 32, FColor::Blue, false);
			const FVector FloorPoint = LocationOnSpline + Distance.ProjectOnToNormal(Rotation.GetAxisZ()).GetSafeNormal() * SegmentDepth/2;
			FVector ToFloorPointDirection = Gravity = (FloorPoint - WorldLocation).GetSafeNormal();
			FVector MobiusUp = Rotation.GetAxisZ();
			if ((Distance).GetSafeNormal().Dot(MobiusUp) < 0.0f)
			{
				MobiusUp = MobiusUp * -1.0f;
			}
			Orientation =  MobiusUp;
			Gravity = ToFloorPointDirection;
		}
		else
		{
			FVector MobiusUp = Rotation.GetAxisZ();
			if ((Distance).GetSafeNormal().Dot(MobiusUp) < 0.0f)
			{
				MobiusUp = MobiusUp * -1.0f;
			}
			
			FVector SideUp = Rotation.GetAxisY();		
		
			if (WidthOffset < 0.0f)
			{
				SideUp = SideUp * -1.0f;
			}

			const float LerpAlpha = 1.0f - FMath::Abs(HeightOffset / (SegmentDepth/2));
			
			Orientation = FVector::SlerpVectorToDirection(MobiusUp, SideUp, LerpAlpha);			
			//DrawDebugDirectionalArrow(GetWorld(), LocationOnSpline, LocationOnSpline + MobiusUp *200.f, 20, FColor::Green, false);
			Gravity = MobiusUp;
			FVector SideDown = SideUp * -1.0f;
			Gravity = FVector::SlerpVectorToDirection(Gravity, SideDown, LerpAlpha);		
		}
	}
	else
	{
		FVector MobiusUp = Rotation.GetAxisZ();
		if ((Distance).GetSafeNormal().Dot(MobiusUp) < 0.0f)
		{
			MobiusUp = MobiusUp * -1.0f;
		}	
		
		Orientation =  MobiusUp;
		Gravity = MobiusUp * -1.0f;
	}

	//DrawDebugDirectionalArrow(GetWorld(), WorldLocation, WorldLocation + Orientation *200.f, 20, FColor::Green, false);
}