How To Make An Arbitrarily Long Spline Mesh In Unreal Engine

Published on • Updated on

The Problem

Unreal Engine has a component called USplineMeshComponent. It does something really cool: deforming a mesh along a spline, so you can take e.g. a straight section of pipe and make it any length, make the ends point in any direction, distort the shape by changing the tangents or the roll, etc. Unfortunately, spline mesh components have one hard-coded limitation: the spline can only have two control points.

In a lot of cases, that's fine. But what if you want an arbitrarily long spline mesh? I couldn't find any good answers or tutorials online, so I ended up making my own. I had to solve the following problems:

The AChainedSplineMesh Class

Let's start with a class declaration, and then fill it out to solve each problem one by one. Here's the final, commented AChainedSplineMesh class:

#pragma once

#include "CoreMinimal.h"
#include "GameFramework/Actor.h"
#include "ChainedSplineMesh.generated.h"

class USplineComponent;
class USplineMeshComponent;

UCLASS()
class CHAINEDSPLINEMESH_API AChainedSplineMesh : public AActor
{
	GENERATED_BODY()

public:
	// Constructor: used to set up normal components
	AChainedSplineMesh();

	// Post-construction hook: used to set up our dynamic components
	virtual void OnConstruction(const FTransform& Transform) override;

protected:
	// The spline we'll be using to shape the chained spline mesh. This is just an ordinary component.
	UPROPERTY(EditDefaultsOnly)
	TObjectPtr<USplineComponent> Spline;

	// The mesh we'll be using as the spline mesh's... mesh. Note that this is just a `UStaticMesh`,
	// NOT a `UStaticMeshComponent`.
	UPROPERTY(EditDefaultsOnly)
	TSoftObjectPtr<UStaticMesh> Mesh;

private:
	// The dynamically-created spline mesh components will be held here, so we can manage their
	// lifecycles appropriately.
	UPROPERTY()
	TArray<TObjectPtr<USplineMeshComponent>> SplineMeshes;

	// Called every time we need to rebuild the spline mesh chain (e.g. when the root spline changes).
	void PopulateSplineMeshes();
};

I'm going to fill in this class piece by piece to arrive at the final result. Here's the full source code if you're in a hurry.

Querying The USplineComponent

This part is actually easy. All we need to do is iterate through each segment of the spline and obtain the following information:

Splines provide easy access to this information through the GetNumberOfSplineSegments() and GetSplinePointAt() methods. So our PopulateSplineMeshes() method will look, overall, something like this:

void AChainedSplineMesh::PopulateSplineMeshes()
{
	for (int32 SegmentIndex = 0; SegmentIndex < Spline->GetNumberOfSplineSegments(); ++SegmentIndex)
	{
		const FSplinePoint Start = Spline->GetSplinePointAt(SegmentIndex, ESplineCoordinateSpace::World);
		const FSplinePoint End = Spline->GetSplinePointAt(SegmentIndex + 1, ESplineCoordinateSpace::World);

		// Add or change our spline mesh components
	}

	// If we lost any segments from the root spline, we need to clean up the associated spline mesh
	// components.
	for (int32 SegmentIndex = Spline->GetNumberOfSplineSegments(); SegmentIndex < SplineMeshes.Num(); ++SegmentIndex)
	{
		// Clean up any extra spline mesh components, if we removed segments from the spline
	}

	// Shrink the component array down to the correct size, so that UE will garbage collect the
	// components we just destroyed.
	SplineMeshes.SetNum(Spline->GetNumberOfSplineSegments());
}

Creating Dynamic Spline Mesh Components

So, the first hurdle is that spline mesh components are, well, components, and we need to make an unknown amount of them depending on how many segments our spline has. UE generally likes the number of components to be static information; you declare one member variable per component, and then populate them with CreateDefaultSubobject<UTheTypeOfComponentYouWantToMake>(TEXT("TheComponentName")). Then you get a nice hierarchical view in the editor when you examine a derived Blueprint class, or in the inspector.

Now, there is a way to create components dynamically, but it's not an especially well-documented way (I'm not even 100% certain I've done it the "intended" way, to be honest). What I do know is that, as of UE 5.4, the following steps will successfully create and register a dynamic component:

  1. Create the component using NewObject<T>(), not CreateDefaultSubobject<T>(). NewObject<T>() is used to instantiate many different kinds of objects (components being one of them), whereas CreateDefaultSubobject<T>() is used specifically to create CDO's (class default objects), which are not something I want to talk about today, but the important part is it only works in constructors.
  2. If you have a scene component, attach it to another component as normal with SetupAttachment().
  3. Call RegisterComponent(). This method does several things, and I am honestly not 100% clear what its purpose is, but leaving it out is a great way to have nothing work at all.

So, we can update our loop from the previous section with the following code:

void AChainedSplineMesh::PopulateSplineMeshes()
{
	for (int32 SegmentIndex = 0; SegmentIndex < Spline->GetNumberOfSplineSegments(); ++SegmentIndex)
	{
		const FSplinePoint Start = Spline->GetSplinePointAt(SegmentIndex, ESplineCoordinateSpace::World);
		const FSplinePoint End = Spline->GetSplinePointAt(SegmentIndex + 1, ESplineCoordinateSpace::World);

		// Change any existing spline mesh components

		// If we reach this point, we must have added some new points to the spline mesh chain, so we
		need to create, attach, register, and track a new segment.
		TObjectPtr<USplineMeshComponent> SplineMesh = NewObject<USplineMeshComponent>(this);
		SplineMesh->SetupAttachment(GetRootComponent());
		SplineMesh->RegisterComponent();
		SplineMeshes.Add(SplineMesh);

		// Bring the position and tangent data from the root spline, and tell the spline mesh which
		// asset it's supposed to use.
		SplineMesh->SetStartAndEnd(Start.Position, Start.LeaveTangent, End.Position, End.ArriveTangent);
		SplineMesh->SetStaticMesh(Mesh.LoadSynchronous());
	}

	// If we lost any segments from the root spline, we need to clean up the associated spline mesh
	// components.
	for (int32 SegmentIndex = Spline->GetNumberOfSplineSegments(); SegmentIndex < SplineMeshes.Num(); ++SegmentIndex)
	{
		// Clean up any extra spline mesh components, if we removed segments from the spline
	}

	// Shrink the component array down to the correct size, so that UE will garbage collect the
	// components we just destroyed.
	SplineMeshes.SetNum(Spline->GetNumberOfSplineSegments());
}

Destroying Dynamic Spline Mesh Components

This part is, fortunately, also very easy. All you have to do is call DestroyComponent() in the second loop. We can also wrap this part in #if WITH_EDITOR, because we should not be changing the spline in a non-editor build.

void AChainedSplineMesh::PopulateSplineMeshes()
{
	// ...

#if WITH_EDITOR
	// If we lost any segments from the root spline, we need to clean up the associated spline mesh
	// components.
	for (int32 SegmentIndex = Spline->GetNumberOfSplineSegments(); SegmentIndex < SplineMeshes.Num(); ++SegmentIndex)
	{
		SplineMeshes[SegmentIndex]->DestroyComponent();
	}

	// Shrink the component array down to the correct size, so that UE will garbage collect the
	// components we just destroyed.
	SplineMeshes.SetNum(Spline->GetNumberOfSplineSegments());
#endif
}

Updating Existing Spline Mesh Components

If we're actively editing a spline, then chances are we will be running PopulateSplineMeshes() a lot. Ideally, we should avoid the performance penalties (and memory churn) of destroying and re-creating the whole spline mesh component chain every time we edit a spline point. So, instead of that, we can add a step to our first loop that reconfigures an existing spline mesh component, if there is one in that segment index.

void AChainedSplineMesh::PopulateSplineMeshes()
{
	for (int32 SegmentIndex = 0; SegmentIndex < Spline->GetNumberOfSplineSegments(); ++SegmentIndex)
	{
		const FSplinePoint Start = Spline->GetSplinePointAt(SegmentIndex, ESplineCoordinateSpace::World);
		const FSplinePoint End = Spline->GetSplinePointAt(SegmentIndex + 1, ESplineCoordinateSpace::World);

#if WITH_EDITOR
		// When we change the spline, we can probably re-use most of the spline mesh segments. Just
		// bring the new data from the root spline and move on to the next segment.
		if (SegmentIndex < SplineMeshes.Num())
		{
			SplineMeshes[SegmentIndex]->SetStartAndEnd(Start.Position, Start.LeaveTangent, End.Position, End.ArriveTangent);
			continue;
		}
#endif

		// ...
	}

	// ...
}

We also used #if WITH_EDITOR again, for the same reason: we will not be changing any spline segments outside of an editor build.

Finishing Up

And that's the tough part done! We only need a couple of other things: the constructor and the post-construction hook (they're different things, I promise).

The Constructor

Nothing complicated here, it's a very standard UE actor constructor.

AChainedSplineMesh::AChainedSplineMesh()
{
	// We don't need tick, so we save the engine the trouble
	PrimaryActorTick.bCanEverTick = false;

	// The spline component is just a reference, not something we want as the root of all our other
	// components, so we just make an empty scene component to serve as the root.
	const auto RootSceneComponent = CreateDefaultSubobject<USceneComponent>(TEXT("RootSceneComponent"));
	RootSceneComponent->Mobility = EComponentMobility::Static;
	SetRootComponent(RootSceneComponent);

	// Create the spline component, which we will reference when we build the spline mesh chain.
	Spline = CreateDefaultSubobject<USplineComponent>(TEXT("Spline"));
	Spline->SetupAttachment(RootSceneComponent);
}

The Post-Construction Hook

This hook is called right after the "construct" Blueprint event. It can be used to run code outside of the fragile environment of the constructor, but still before BeginPlay(). Importantly for us, this includes at edit time: every time we move, rotate, scale, undo, redo, etc., this code will run.

void AChainedSplineMesh::OnConstruction(const FTransform& Transform)
{
	Super::OnConstruction(Transform);

	// Create the initial set of spline meshes
	PopulateSplineMeshes();

	// Only do this code if we're an editor build; otherwise, there's no way the spline can change,
	// and so no reason to monitor it for changes
#if WITH_EDITOR
	// Remove any previously registered callbacks, so they don't stack up
	Spline->OnDeselectedInEditor.RemoveAll(this);

	// Add a callback so that, when we click off the spline in the editor, the spline mesh chain is
	// rebuilt. I wish there was a better hook for this, but splines only give us this one. You could
	// probably subclass `USplineComponent` to add better hooks, if you like.
	Spline->OnDeselectedInEditor.AddLambda([this](TObjectPtr<USplineComponent>) { PopulateSplineMeshes(); });
#endif
}

Done!

I'll get some pictures in here soon, but really, you should test this code out with your own meshes. You should be able to manipulate the root spline as normal (this can be confusing; click the actor once to select it, then click a control point to select the spline, then right click somewhere on the spline to add or delete points. I know, I hate it too). Then as soon as you de-select the actor, the spline mesh chain should update, deforming the relevant parts of the mesh or adding and removing segments as you add and remove control points.

If you made it all the way to the end, I hope you found this useful! Shoot me an email if you have any ideas to make this solution better. Thanks for reading!

Addendum: Full Source Code

ChainedSplineMesh.h

#pragma once

#include "CoreMinimal.h"
#include "GameFramework/Actor.h"
#include "ChainedSplineMesh.generated.h"

class USplineComponent;
class USplineMeshComponent;

UCLASS()
class CHAINEDSPLINEMESH_API AChainedSplineMesh : public AActor
{
	GENERATED_BODY()

public:
	// Constructor: used to set up normal components
	AChainedSplineMesh();

	// Post-construction hook: used to set up our dynamic components
	virtual void OnConstruction(const FTransform& Transform) override;

protected:
	// The spline we'll be using to shape the chained spline mesh. This is just an ordinary component.
	UPROPERTY(EditDefaultsOnly)
	TObjectPtr<USplineComponent> Spline;

	// The mesh we'll be using as the spline mesh's... mesh. Note that this is just a `UStaticMesh`,
	// NOT a `UStaticMeshComponent`.
	UPROPERTY(EditDefaultsOnly)
	TSoftObjectPtr<UStaticMesh> Mesh;

private:
	// The dynamically-created spline mesh components will be held here, so we can manage their
	// lifecycles appropriately.
	UPROPERTY()
	TArray<TObjectPtr<USplineMeshComponent>> SplineMeshes;

	// Called every time we need to rebuild the spline mesh chain (e.g. when the root spline changes).
	void PopulateSplineMeshes();
};

ChainedSplineMesh.cpp

#include "ChainedSplineMesh.h"

#include "Components/SplineComponent.h"
#include "Components/SplineMeshComponent.h"

AChainedSplineMesh::AChainedSplineMesh()
{
	// We don't need tick, so we save the engine the trouble
	PrimaryActorTick.bCanEverTick = false;

	// The spline component is just a reference, not something we want as the root of all our other
	// components, so we just make an empty scene component to serve as the root.
	const auto RootSceneComponent = CreateDefaultSubobject<USceneComponent>(TEXT("RootSceneComponent"));
	RootSceneComponent->Mobility = EComponentMobility::Static;
	SetRootComponent(RootSceneComponent);

	// Create the spline component, which we will reference when we build the spline mesh chain.
	Spline = CreateDefaultSubobject<USplineComponent>(TEXT("Spline"));
	Spline->SetupAttachment(RootSceneComponent);
}

void AChainedSplineMesh::OnConstruction(const FTransform& Transform)
{
	Super::OnConstruction(Transform);

	// Create the initial set of spline meshes
	PopulateSplineMeshes();

	// Only do this code if we're an editor build; otherwise, there's no way the spline can change,
	// and so no reason to monitor it for changes
#if WITH_EDITOR
	// Remove any previously registered callbacks, so they don't stack up
	Spline->OnDeselectedInEditor.RemoveAll(this);

	// Add a callback so that, when we click off the spline in the editor, the spline mesh chain is
	// rebuilt. I wish there was a better hook for this, but splines only give us this one. You could
	// probably subclass `USplineComponent` to add better hooks, if you like.
	Spline->OnDeselectedInEditor.AddLambda([this](TObjectPtr<USplineComponent>) { PopulateSplineMeshes(); });
#endif
}

void AChainedSplineMesh::PopulateSplineMeshes()
{
	for (int32 SegmentIndex = 0; SegmentIndex < Spline->GetNumberOfSplineSegments(); ++SegmentIndex)
	{
		const FSplinePoint Start = Spline->GetSplinePointAt(SegmentIndex, ESplineCoordinateSpace::World);
		const FSplinePoint End = Spline->GetSplinePointAt(SegmentIndex + 1, ESplineCoordinateSpace::World);

#if WITH_EDITOR
		// When we change the spline, we can probably re-use most of the spline mesh segments. Just
		// bring the new data from the root spline and move on to the next segment.
		if (SegmentIndex < SplineMeshes.Num())
		{
			SplineMeshes[SegmentIndex]->SetStartAndEnd(Start.Position, Start.LeaveTangent, End.Position, End.ArriveTangent);
			continue;
		}
#endif

		// If we reach this point, we must have added some new points to the spline mesh chain, so we
		need to create, attach, register, and track a new segment.
		TObjectPtr<USplineMeshComponent> SplineMesh = NewObject<USplineMeshComponent>(this);
		SplineMesh->SetupAttachment(GetRootComponent());
		SplineMesh->RegisterComponent();
		SplineMeshes.Add(SplineMesh);

		// Bring the position and tangent data from the root spline, and tell the spline mesh which mesh
		// it's supposed to use.
		SplineMesh->SetStartAndEnd(Start.Position, Start.LeaveTangent, End.Position, End.ArriveTangent);
		SplineMesh->SetStaticMesh(Mesh.LoadSynchronous());
	}

#if WITH_EDITOR
	// If we lost any segments from the root spline, we need to clean up the associated spline mesh
	// components.
	for (int32 SegmentIndex = Spline->GetNumberOfSplineSegments(); SegmentIndex < SplineMeshes.Num(); ++SegmentIndex)
	{
		SplineMeshes[SegmentIndex]->DestroyComponent();
	}

	// Shrink the component array down to the correct size, so that UE will garbage collect the
	// components we just destroyed.
	SplineMeshes.SetNum(Spline->GetNumberOfSplineSegments());
#endif
}