Лента Мёбиуса. Часть 1.
В свой pet-проект я захотел добавить процедурный уровень на основе ленты Мёбиуса. Пока я собирал функционал и исследовал возможности движка, я узнал много полезных вещей, в частности связанных с кастомной гравитацией и с динамическими мешами в UE. Решил поделиться с вами этими находками.
Уровень лента
.Для начала я определился с требованиями к генератору:
- Размер и положение ленты должно быть удобно для настройки.
- Лента должна быть процедурно генерируемая.
- Алгоритм должен быть простой и достаточно быстрый, чтобы я мог использовать её в рантайме со случайными параметрами, чтобы генерировать новые уровни.
С такими требованиями я принял пару решений. За основу взял зацикленный сплайн в USplineComponent. Для отображения сначала попробовал использовать UProceduralMeshComponent, но потом поменял свое решение в пользу UDynamicMeshComponent. Во-первых, потому что динамические меши добавили в 5.0 и их сейчас активно развивают. Во вторых, для них написано много реализации в UGeometryScriptLibrary_MeshPrimitiveFunctions, куда я могу обращаться за примерами.
Генерация Ориентации.
Способом генерации я выбрал вращение вертикального вектора, в частности:
- Разбиваем весь сплайн на равные участки нужной длины.(это позволит мне контролировать детализацию ленты)
- Проходим все участки в цикле, каждый раз поворачивая вертикальный вектор на угол равный 360.0/количество участков.
- Генерируем вершины нашей ленты, и соединяем их в треугольники, которые отправляем в dynamicmesh.
Для проверки теории я решил быстро реализовать первые два пункта и проверить, что я правильно разобрался в интерфейсе USplineComponent.
UCLASS()
class FPSGAME_API ACRMobiusStrip : public AActor
{
GENERATED_BODY()
public:
// Sets default values for this actor's properties
ACRMobiusStrip();
UFUNCTION(CallInEditor, BlueprintCallable)
void GenerateStrip();
protected:
UPROPERTY(EditAnywhere)
float SegmentLenght = 100;
UPROPERTY(EditAnywhere)
float SegmentWidth = 500;
UPROPERTY(VisibleAnywhere,BlueprintReadWrite)
class UDynamicMeshComponent* MobiusMesh;
UPROPERTY(VisibleAnywhere,BlueprintReadWrite)
class USplineComponent* SplineComponent;
UPROPERTY(VisibleAnywhere,BlueprintReadWrite)
class USplineComponent* MobiusSplineComponent;
};N.B. полезный атрибут функции в UE CallInEditor. Он добавляет кнопку в редактор, которая и вызывает функцию. Это очень удобно для тестирования генераторов и неигровых логик.
void ACRMobiusStrip::GenerateStrip()
{
float splineLenght = SplineComponent->GetSplineLength();
int numOfSteps = FMath::FloorToInt(splineLenght/SegmentLenght);
FVector UpVector = FVector::UpVector;
for (int index = 0; index <= numOfSteps; ++index )
{
FVector Location = SplineComponent->GetLocationAtDistanceAlongSpline(SegmentLenght * index, ESplineCoordinateSpace::Type::World);
FRotator Direction = SplineComponent->GetRotationAtDistanceAlongSpline(SegmentLenght * index, ESplineCoordinateSpace::Type::Local);
FVector DirectionUp = UpVector.RotateAngleAxis(360.0/numOfSteps *index, Direction.Vector());
DrawDebugDirectionalArrow(GetWorld(), Location, Location + DirectionUp *200.f, 20, FColor::Green, false);
}
}
Я убрал из статьи инициализации компонентов и конструктор, ибо они совершенно обычные и я ничего интересного там не делал.
В результате получаем следующую картину:
Треугольники
Теперь осталось построить прямоугольники на каждом участке, и лента готова.
Подглядев на то, как с UDynamicMeshComponent работают в движке, я увидел два способа: собрать свою версию FMeshShapeGenerator , либо взять меш из компонента и настраивать вершины и треугольники напрямую. Я выбрал второй путь и остался доволен, так как это позволило мне контролировать материалы и UV в одном месте.
Давайте начнем создавать вершины. На каждом участке делаем две вершины в плоскости, для которой наш повернутый вектор будет нормалью, сделав отступ вправо и влево от точки на сплайне.
FVector Vert0 = Location + Rotator.RotateVector(FVector::RightVector) * SegmentWidth /2; FVector Vert1 = Location + Rotator.RotateVector(FVector::RightVector) * SegmentWidth /2;
Добавим эти вершины в наш меш, сохраним индекс первой вершины:
int StartVertIndex = MobiusMesh3->AppendVertex(UE::Geometry::FVertexInfo(Vert0)); MobiusMesh3->AppendVertex(UE::Geometry::FVertexInfo(Vert1));
Теперь для всех шагов, кроме первого (на первом у нас всего 2 вершины, треугольник не сделать), возьмем вершины с прошлого шага и сделаем из них 2 треугольника.
if (index != 0)
{
UE::Geometry::FIndex3i Tri(PrevStartVertIndex, StartVertIndex, StartVertIndex + 1 );
MobiusMesh3->AppendTriangle(Tri);
Tri = UE::Geometry::FIndex3i(StartVertIndex + 1 , PrevStartVertIndex + 1,PrevStartVertIndex);
MobiusMesh3->AppendTriangle(Tri);
}
PrevStartVertIndex = StartVertIndex;N.B. Есть несколько нюансов работы с UDynamicMeshComponent , которые я узнал, работая с этой лентой, и о которых хотел бы рассказать.
Чтобы получить меш внутри компонента и работать, есть удобная функция
DynamicMesh3* MobiusMesh3 = MobiusMesh->GetDynamicMesh()->GetMeshPtr();
Не забудьте включить "DynamicMesh" в публичные зависимости вашего проекта, иначе линтер не даст вам собрать проект.
Чтобы применить изменения в меше, нужно вызвать NotifyMeshUpdated на компоненте.
Сразу видны три проблемы: первая - это не лента мебиуса, а просто перекрученная лента. И вправду - мой вектор нормали делает полный оборот, и поэтому получается две поверхности. Заменим нашу константу на настраиваемый параметр количества полуоборотов, что даст мне возможность генерировать любые ленты. Вторая проблема - это отсутствие последних треугольников, которые замкнут петлю. С этой проблемой мы разберемся в конце. Последняя проблема - это то, что лента видна только с одной стороны. Эту проблему можно решить с помощью шейдера, но я все равно хотел добавить толщины ленте (так как игрок будет двигаться по ней, он должен иметь возможность увидеть грань).
Прежде чем добавлять толщину, приведу полный код с исправленной проблемой полного оборота.
UCLASS()
class FPSGAME_API ACRMobiusStrip : public AActor
{
GENERATED_BODY()
.
.
.
UPROPERTY(EditAnywhere)
int NumberOfHalfTurns = 1;
float TotalAngle = 180.0f;
.
.
.
}void ACRMobiusStrip::GenerateStrip()
{
float splineLenght = SplineComponent->GetSplineLength();
int numOfSteps = FMath::FloorToInt(splineLenght/SegmentLenght);
FVector UpVector = FVector::UpVector;
TotalAngle = NumberOfHalfTurns * 180.0f;
FDynamicMesh3* MobiusMesh3 = MobiusMesh->GetDynamicMesh()->GetMeshPtr();
MobiusMesh3->Clear();
int PrevStartVertIndex = 0;
for (int index = 0; index <= numOfSteps; ++index )
{
FVector Location = SplineComponent->GetLocationAtDistanceAlongSpline(SegmentLenght * index, ESplineCoordinateSpace::Type::Local);
FRotator Direction = SplineComponent->GetRotationAtDistanceAlongSpline(SegmentLenght * index, ESplineCoordinateSpace::Type::Local);
FVector DirectionUp = UpVector.RotateAngleAxis(360.0/numOfSteps *index, Direction.Vector());
FRotator Rotator = FRotationMatrix::MakeFromZX(DirectionUp, Direction.Vector()).Rotator();
FVector Vert0 = Location + Rotator.RotateVector(FVector::RightVector) * SegmentWidth /2;
FVector Vert1 = Location - Rotator.RotateVector(FVector::RightVector) * SegmentWidth /2;
int StartVertIndex = MobiusMesh3->AppendVertex(UE::Geometry::FVertexInfo(Vert0));
MobiusMesh3->AppendVertex(UE::Geometry::FVertexInfo(Vert1));
if (index != 0)
{
UE::Geometry::FIndex3i Tri(PrevStartVertIndex, StartVertIndex, StartVertIndex + 1 );
MobiusMesh3->AppendTriangle(Tri);
Tri = UE::Geometry::FIndex3i(StartVertIndex + 1 , PrevStartVertIndex + 1,PrevStartVertIndex);
MobiusMesh3->AppendTriangle(Tri);
}
PrevStartVertIndex = StartVertIndex;
}
Толщина
Теперь добавим толщину: нам понадобятся еще две вершины. Наши старые вершины поднимем на половину заданной высоты (SegmentDepth) вдоль нормали нашей плоскости, а новые опустим вниз.
FVector Vert2 = Location - DirectionUp * SegmentDepth/2 + Rotator.RotateVector(FVector::RightVector) * SegmentWidth /2; FVector Vert3 = Location - DirectionUp * SegmentDepth/2 - Rotator.RotateVector(FVector::RightVector) * SegmentWidth /2; . . . MobiusMesh3->AppendVertex(UE::Geometry::FVertexInfo(Vert2)); MobiusMesh3->AppendVertex(UE::Geometry::FVertexInfo(Vert3));
Теперь у нас 4 узла и мы можем собрать верхнюю, нижнюю и боковые плоскости нашей “ленты”.
//up plane UE::Geometry::FIndex3i Tri(PrevStartVertIndex, StartVertIndex, StartVertIndex + 1 ); MobiusMesh3->AppendTriangle(Tri); Tri = UE::Geometry::FIndex3i(StartVertIndex + 1 , PrevStartVertIndex + 1,PrevStartVertIndex); MobiusMesh3->AppendTriangle(Tri); //bottom plane Tri = UE::Geometry::FIndex3i(PrevStartVertIndex + 3, StartVertIndex + 3, StartVertIndex + 2 ); MobiusMesh3->AppendTriangle(Tri); Tri = UE::Geometry::FIndex3i(StartVertIndex + 2, PrevStartVertIndex + 2, PrevStartVertIndex + 3 ); MobiusMesh3->AppendTriangle(Tri); //left side Tri = UE::Geometry::FIndex3i(PrevStartVertIndex + 2, StartVertIndex+ 2 , StartVertIndex ); MobiusMesh3->AppendTriangle(Tri); Tri = UE::Geometry::FIndex3i(StartVertIndex, PrevStartVertIndex, PrevStartVertIndex+ 2 ); MobiusMesh3->AppendTriangle(Tri); //right side Tri = UE::Geometry::FIndex3i(PrevStartVertIndex + 1, StartVertIndex +1, StartVertIndex + 3 ); MobiusMesh3->AppendTriangle(Tri); Tri = UE::Geometry::FIndex3i(StartVertIndex + 3, PrevStartVertIndex + 3, PrevStartVertIndex + 1); MobiusMesh3->AppendTriangle(Tri);
Запускаем и получаем результат
Теперь давайте замкнем нашу петлю. С помощью блокнота и ручки я сумел понять, какие вершины с какими образовывают треугольник, и написал отдельную логику для последней секции.
//we did half full turn we need consider it in indexes
if (index == numOfSteps && NumberOfHalfTurns % 2 != 0)
{
//up plane
UE::Geometry::FIndex3i Tri(PrevStartVertIndex + 3, StartVertIndex, StartVertIndex + 1 );
MobiusMesh3->AppendTriangle(Tri);
Tri = UE::Geometry::FIndex3i(StartVertIndex + 1 , PrevStartVertIndex + 2,PrevStartVertIndex + 3);
MobiusMesh3->AppendTriangle(Tri);
//bottom plane
Tri = UE::Geometry::FIndex3i(StartVertIndex + 3, StartVertIndex + 2, PrevStartVertIndex + 1);
MobiusMesh3->AppendTriangle(Tri);
Tri = UE::Geometry::FIndex3i(PrevStartVertIndex + 1 , PrevStartVertIndex , StartVertIndex + 3);
MobiusMesh3->AppendTriangle(Tri);
//left side
Tri = UE::Geometry::FIndex3i(PrevStartVertIndex + 1, StartVertIndex+ 2 , StartVertIndex );
MobiusMesh3->AppendTriangle(Tri);
Tri = UE::Geometry::FIndex3i(StartVertIndex, PrevStartVertIndex+3, PrevStartVertIndex+ 1 );
MobiusMesh3->AppendTriangle(Tri);
}
Я не стал подробно объяснять, как я собирал треугольники из вершин. Там был простой и прямолинейный подход. Но если это кажется полезным, оставьте комментарий, и я подробно распишу как я собирал треугольники.
Первое, что я сделал - попробовал запрыгнуть на неё персонажем. Естественно коллизий у нее не было. Теоретически можно было завести кучу коробочек для моей ленты, но с учетом того, что она одна на уровне, и меш достаточно простой, я доверился оптимизации UE и просто включил сложные коллизии:
MobiusMesh->SetComplexAsSimpleCollisionEnabled(true);
UV и материалы.
Для того, чтобы включить UV развертку и материалы на меше, на нем надо активировать атрибуты с помощью функции EnableAttributes().
MobiusMesh3->EnableAttributes();
Для того, чтобы добавлять UV к конкретным треугольникам, удобно взять ссылки на два объекта FDynamicMeshUVOverlay и FDynamicMeshMaterialAttribute.
UE::Geometry::FDynamicMeshUVOverlay* UVOverlay
= MobiusMesh3->Attributes()->PrimaryUV();
UE::Geometry::FDynamicMeshMaterialAttribute* MaterialAttribute
= MobiusMesh3->Attributes()->GetMaterialID();Для того, чтобы проставить UV или материал, надо знать индекс треугольника (его возвращает функция SetTriangle)
UVOverlay->SetTriangle(tid, UVUpTri); MaterialAttribute->SetNewValue(tid, 1);
Для того, чтобы назначить конкретный материал на меш, нужно его передать уже в компонент.
MobiusMesh->SetMaterial(0, BaseMaterial);
Я завел поле для материала, в моем классе.
UPROPERTY(EditAnywhere) UMaterialInterface* BaseMaterial;
Для UV развертки я решил, что у меня будет очень длинная лента в UV пространстве, которую я переадресую на мои треугольники равномерно. Будем добавлять точки к нашему UVOverlay на каждом шаге цикла, генерируя небольшую полоску в UV пространстве, на которую будем проецировать все наши треугольники в этом шаге цикла.
float UVYOffest = SegmentLenght * index/ SegmentWidth; int StartUVIndex = UVOverlay->AppendElement(FVector2f(0.0f, UVYOffest)); UVOverlay->AppendElement(FVector2f(1.0f,UVYOffest));
Создаем два треугольника в UV пространстве.
UE::Geometry::FIndex3i UVUpTri(PrevStartUVIndex, StartUVIndex, StartUVIndex + 1 ); UE::Geometry::FIndex3i UVDownTri(StartUVIndex+1, PrevStartUVIndex+1, PrevStartUVIndex);
Сохраняем индексы наших треугольников и добавляем адресацию между треугольниками меша и UVOverlay
UE::Geometry::FIndex3i Tri(PrevStartVertIndex, StartVertIndex, StartVertIndex + 1 ); int tid = MobiusMesh3->AppendTriangle(Tri); UVOverlay->SetTriangle(tid, UVUpTri); Tri = UE::Geometry::FIndex3i(StartVertIndex + 1 , PrevStartVertIndex + 1,PrevStartVertIndex); tid = MobiusMesh3->AppendTriangle(Tri); UVOverlay->SetTriangle(tid, UVDownTri);
Повторяем для всех треугольников. Назначаем тестовый материал для дебага UV.
N.B. для последнего шага цикла, когда мы замыкаем петлю, мне понадобилась дополнительная магия с UV, чтобы все красиво сошлось в месте начала петли. Я честно добился правильной развертки методом проб и ошибок, но наверное это можно было вычислить как-то заранее.
В итоге я получил вот такой меш.
P.S.
Финальный код если кто то решит его прочитать
void ACRMobiusStrip::GenerateStrip()
{
float splineLenght = SplineComponent->GetSplineLength();
int numOfSteps = FMath::FloorToInt(splineLenght/SegmentLenght);
FVector UpVector = FVector::UpVector;
TotalAngle = NumberOfHalfTurns * 180.0f;
FDynamicMesh3* MobiusMesh3 = MobiusMesh->GetDynamicMesh()->GetMeshPtr();
MobiusMesh3->Clear();
MobiusMesh3->EnableAttributes();
MobiusMesh3->Attributes()->EnableMaterialID();
UE::Geometry::FDynamicMeshUVOverlay* UVOverlay = MobiusMesh3->Attributes()->PrimaryUV();
UE::Geometry::FDynamicMeshMaterialAttribute* MaterialAttribute = MobiusMesh3->Attributes()->GetMaterialID();
int PrevStartVertIndex = 0;
int PrevStartUVIndex = 0;
for (int index = 0; index <= numOfSteps; ++index )
{
FVector Location = SplineComponent->GetLocationAtDistanceAlongSpline(SegmentLenght * index, ESplineCoordinateSpace::Type::Local);
FRotator Direction = SplineComponent->GetRotationAtDistanceAlongSpline(SegmentLenght * index, ESplineCoordinateSpace::Type::Local);
FVector DirectionUp = UpVector.RotateAngleAxis(TotalAngle/numOfSteps *index, Direction.Vector());
FRotator Rotator = FRotationMatrix::MakeFromZX(DirectionUp, Direction.Vector()).Rotator();
int StartVertIndex = 0;
if (index != numOfSteps)
{
FVector Vert0 = Location + DirectionUp * SegmentDepth/2 + Rotator.RotateVector(FVector::RightVector) * SegmentWidth /2;
FVector Vert1 = Location + DirectionUp * SegmentDepth/2 - Rotator.RotateVector(FVector::RightVector) * SegmentWidth /2;
FVector Vert2 = Location - DirectionUp * SegmentDepth/2 + Rotator.RotateVector(FVector::RightVector) * SegmentWidth /2;
FVector Vert3 = Location - DirectionUp * SegmentDepth/2 - Rotator.RotateVector(FVector::RightVector) * SegmentWidth /2;
StartVertIndex = MobiusMesh3->AppendVertex(UE::Geometry::FVertexInfo(Vert0));
MobiusMesh3->AppendVertex(UE::Geometry::FVertexInfo(Vert1));
MobiusMesh3->AppendVertex(UE::Geometry::FVertexInfo(Vert2));
MobiusMesh3->AppendVertex(UE::Geometry::FVertexInfo(Vert3));
}
float UVYOffest = SegmentLenght * index/ SegmentWidth;
int StartUVIndex = UVOverlay->AppendElement(FVector2f(0.0f, UVYOffest));
UVOverlay->AppendElement(FVector2f(1.0f,UVYOffest));
if (index != 0)
{
UE::Geometry::FIndex3i UVUpTri(PrevStartUVIndex, StartUVIndex, StartUVIndex + 1 );
UE::Geometry::FIndex3i UVDownTri(StartUVIndex+1, PrevStartUVIndex+1, PrevStartUVIndex);
//we did half full turn we need consider it in indexes
if (index == numOfSteps && NumberOfHalfTurns % 2 != 0)
{
//up plane
UE::Geometry::FIndex3i Tri(PrevStartVertIndex + 3, StartVertIndex, StartVertIndex + 1 );
int tid = MobiusMesh3->AppendTriangle(Tri);
UVOverlay->SetTriangle(tid, UVUpTri);
Tri = UE::Geometry::FIndex3i(StartVertIndex + 1 , PrevStartVertIndex + 2,PrevStartVertIndex + 3);
tid = MobiusMesh3->AppendTriangle(Tri);
UVOverlay->SetTriangle(tid, UVDownTri);
//bottom plane
UVUpTri = UE::Geometry::FIndex3i (StartUVIndex, StartUVIndex + 1, PrevStartUVIndex+1 );
UVDownTri = UE::Geometry::FIndex3i(PrevStartUVIndex+1, PrevStartUVIndex, StartUVIndex);
Tri = UE::Geometry::FIndex3i(StartVertIndex + 3, StartVertIndex + 2, PrevStartVertIndex + 1);
tid = MobiusMesh3->AppendTriangle(Tri);
UVOverlay->SetTriangle(tid, UVUpTri);
Tri = UE::Geometry::FIndex3i(PrevStartVertIndex + 1 , PrevStartVertIndex , StartVertIndex + 3);
tid = MobiusMesh3->AppendTriangle(Tri);
UVOverlay->SetTriangle(tid, UVDownTri);
//left side
UVUpTri = UE::Geometry::FIndex3i (PrevStartUVIndex , StartUVIndex, StartUVIndex + 1);
UVDownTri = UE::Geometry::FIndex3i(StartUVIndex + 1, PrevStartUVIndex+1, PrevStartUVIndex);
Tri = UE::Geometry::FIndex3i(PrevStartVertIndex + 1, StartVertIndex+ 2 , StartVertIndex);
tid = MobiusMesh3->AppendTriangle(Tri);
UVOverlay->SetTriangle(tid, UVUpTri);
Tri = UE::Geometry::FIndex3i(StartVertIndex, PrevStartVertIndex+3, PrevStartVertIndex+ 1 );
tid = MobiusMesh3->AppendTriangle(Tri);
UVOverlay->SetTriangle(tid, UVDownTri);
//right side
Tri = UE::Geometry::FIndex3i(PrevStartVertIndex + 2, StartVertIndex +1, StartVertIndex + 3 );
tid = MobiusMesh3->AppendTriangle(Tri);
UVOverlay->SetTriangle(tid, UVUpTri);
Tri = UE::Geometry::FIndex3i(StartVertIndex + 3, PrevStartVertIndex , PrevStartVertIndex + 2);
tid = MobiusMesh3->AppendTriangle(Tri);
UVOverlay->SetTriangle(tid, UVDownTri);
}
else
{
//up plane
UE::Geometry::FIndex3i Tri(PrevStartVertIndex, StartVertIndex, StartVertIndex + 1 );
int tid = MobiusMesh3->AppendTriangle(Tri);
UVOverlay->SetTriangle(tid, UVUpTri);
Tri = UE::Geometry::FIndex3i(StartVertIndex + 1 , PrevStartVertIndex + 1,PrevStartVertIndex);
tid = MobiusMesh3->AppendTriangle(Tri);
UVOverlay->SetTriangle(tid, UVDownTri);
//bottom plane
Tri = UE::Geometry::FIndex3i(PrevStartVertIndex + 3, StartVertIndex + 3, StartVertIndex + 2 );
tid = MobiusMesh3->AppendTriangle(Tri);
UVOverlay->SetTriangle(tid, UVUpTri);
Tri = UE::Geometry::FIndex3i(StartVertIndex + 2, PrevStartVertIndex + 2, PrevStartVertIndex + 3 );
tid = MobiusMesh3->AppendTriangle(Tri);
UVOverlay->SetTriangle(tid, UVDownTri);
//left side
Tri = UE::Geometry::FIndex3i(PrevStartVertIndex + 2, StartVertIndex+ 2 , StartVertIndex );
tid = MobiusMesh3->AppendTriangle(Tri);
UVOverlay->SetTriangle(tid, UVUpTri);
Tri = UE::Geometry::FIndex3i(StartVertIndex, PrevStartVertIndex, PrevStartVertIndex+ 2 );
tid = MobiusMesh3->AppendTriangle(Tri);
UVOverlay->SetTriangle(tid, UVDownTri);
//right side
Tri = UE::Geometry::FIndex3i(PrevStartVertIndex + 1, StartVertIndex +1, StartVertIndex + 3 );
tid = MobiusMesh3->AppendTriangle(Tri);
UVOverlay->SetTriangle(tid, UVUpTri);
Tri = UE::Geometry::FIndex3i(StartVertIndex + 3, PrevStartVertIndex + 3, PrevStartVertIndex + 1);
tid = MobiusMesh3->AppendTriangle(Tri);
UVOverlay->SetTriangle(tid, UVDownTri);
}
}
PrevStartUVIndex = StartUVIndex;
PrevStartVertIndex = StartVertIndex;
}
MobiusMesh->NotifyMeshUpdated();
MobiusMesh->SetMaterial(0, BaseMaterial);
}