Лента Мёбиуса часть 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);
}