Path Guided Camera Transition

Implementing Camera Path Navigation Transition in UE5

In the previous post, I introduced how to implement inertialized camera transitions in UE5. This type of transition guarantees that velocity and acceleration are continuous at both the start and the end, making it highly suitable for transitions between Gameplay cameras and Cinematic cameras.

However, in actual game development, we often need to use an explicitly provided path (Spline) to guide the camera transition path, similar to a PathNode. This is usually done for two reasons:

  1. To avoid collisions during the transition process.
  2. To ensure the camera moves along a specific path to satisfy a particular design requirement.

For example, in the image below, given a specific Spline section, we want the camera to move along this Spline as much as possible.

However, this raises two additional problems:

  1. The starting point of the camera is often not fixed. How do we ensure a natural connection from the starting point to the Spline?
  2. How do we ensure smooth camera movement throughout the entire transition process, rather than having it speed up and slow down erratically?

This article will introduce two methods to implement a Path Guided Transition.

Method 1: Inertialized Transition As A Bridge

Since we want the camera to remain smooth throughout the transition, the natural question is: can we utilize the Inertialized Camera Transition to achieve this smoothness?

Yes, we certainly can. If we create a Virtual Camera on the Spline provided by the designer and have this camera move according to a specified movement curve, we can then utilize two Inertialized Transitions to achieve an overall smooth transition.

The first Inertialized Transition occurs between the Source Camera and the Virtual Camera, ensuring a smooth transition from the initial camera to the Virtual Camera. The second Inertialized Transition occurs between the Virtual Camera and the Target Camera, ensuring a smooth transition from the Virtual Camera to the destination camera.

To do this, we need to specify the following parameters:

  1. Driving Transition: The transition that drives the progression from Source Camera to Target Camera (typically a Smooth/Linear/Cubic Transition).
  2. Rail: The Spline provided by the designer.
  3. Guide Range: The timing for entering the Virtual Camera from the Source Camera, and the timing for exiting the Virtual Camera to transition to the Target Camera. Time is normalized to [0,1][0,1].
  4. Spline Move Curve: How the Virtual Camera moves along the Spline. Time is normalized to [0,1][0,1].

Below is the resulting effect:

And the generated path:

It looks pretty good. However, this method requires precise fine-tuning of several parameters, especially the Spline Move Curve, to ensure overall velocity smoothness.

Method 2: Construct Full-Path Transition Trajectory

The first method requires too many parameters. Can we avoid manual tuning? In other words, can the designer simply configure a Driving Transition, and the camera automatically moves along the path at the specified speed?

Naturally, this is possible. We just need to construct the complete transition path at the moment the transition begins. The designer provides a part of the path, and we need to fill in the missing segments at the beginning and the end.

Unreal’s Spline uses Cubic Hermite Splines by default. Each Spline segment is defined by two target points p0,p1\mathbf{p}_0, \mathbf{p}_1 and their corresponding tangents d0,d1\mathbf{d}_0,\mathbf{d}_1. Since we already know the Spline provided by the designer, as well as the positions of the initial camera and the target camera, we can construct optimized start and end segments. By stitching these with the designer-provided Spline, we form a complete camera movement path.

The constructed path is as follows:

The actual movement is as follows:

However, a drawback of this method is that the endpoint of the path is determined when the Transition starts. If the target camera moves during this process, a “pop” or sudden jump will occur at the end of the transition. Of course, we could dynamically modify the constructed Spline during the transition, but the overhead of using Unreal’s default functions is too high, so we might need to use empirical methods for a more efficient implementation. Secondly, when constructing the start and end segments, the corresponding tangent d\mathbf{d} uses the camera’s own velocity. The Spline constructed this way may not necessarily yield the best path globally, so more global factors need to be considered when selecting tangents.

Reference Code

PathGuidedTransition.h:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
// Copyright Sulley. All rights reserved.

#pragma once

#include "CoreMinimal.h"
#include "ComposableCameraTransitionBase.h"
#include "ComposableCameraPathGuidedTransition.generated.h"

class USplineComponent;
class ACameraRig_Rail;
class UComposableCameraInertializedTransition;

UENUM()
enum class EComposableCameraPathGuidedTransitionType : uint8
{
// Use inertialized camera as a bridge to achieve path guided transition.
Inertialized,

// Use auto-generated splines to achieve path guided transition. \n
// @NOTE: This type won't update TargetCameraPose, so if the target camera is moving during transition, DO NOT use this type.
Auto
};

/**
* A transition which utilizes a path (spline) to guide its position during transition.
* This transition leverages two InertializedTransitions to achieve smoothness.
* An intermediate camera will be spawned as a wrapper for the spline.
* So this transition will be more expensive than other transitions.
*/
UCLASS(ClassGroup = ComposableCameraSystem)
class COMPOSABLECAMERASYSTEM_API UComposableCameraPathGuidedTransition : public UComposableCameraTransitionBase
{
GENERATED_BODY()

public:
virtual void OnBeginPlay_Implementation(float DeltaTime, const FComposableCameraPose& CurrentTargetPose) override;
virtual FComposableCameraPose OnEvaluate_Implementation(float DeltaTime, const FComposableCameraPose& CurrentTargetPose) override;

public:
// Driving transition for base camera transition. Used for both Inertialized and Auto.
UPROPERTY(EditAnywhere, BlueprintReadWrite, Instanced)
UComposableCameraTransitionBase* DrivingTransition;

// Type of path guided transition.
UPROPERTY(BlueprintReadWrite, EditAnywhere)
EComposableCameraPathGuidedTransitionType Type { EComposableCameraPathGuidedTransitionType::Inertialized };

// The rail actor thet contains the desired guiding spline. The tangents of the spline should not be too small nor too large.
UPROPERTY(BlueprintReadWrite, EditAnywhere, meta = (EditCondition = "Type == EComposableCameraPathGuidedTransitionType::Inertialized", EditConditionHides))
TSoftObjectPtr<ACameraRig_Rail> RailActor;

// Normalized timestamps to start/end guide. It's recommended to set a not-close-to-one end timestamp ensuring the camera can return to the desired target position smoothly.
UPROPERTY(BlueprintReadWrite, EditAnywhere, meta = (ClampMin = "0", ClampMax = "1", EditCondition = "Type == EComposableCameraPathGuidedTransitionType::Inertialized", EditConditionHides))
FVector2D GuideRange { 0.25, 0.75 };

// How the virtual camera should move on spline. This curve is normalized. Input range is [0,1], start c[0]=0, c[1]=1.
UPROPERTY(BlueprintReadWrite, EditAnywhere, meta = (EditCondition = "Type == EComposableCameraPathGuidedTransitionType::Inertialized", EditConditionHides))
UCurveFloat* SplineMoveCurve;

private:
UPROPERTY()
AComposableCameraCameraBase* IntermediateCamera { nullptr };

UPROPERTY()
ACameraRig_Rail* Rail;

UPROPERTY()
UComposableCameraInertializedTransition* EnterTransition { nullptr };

UPROPERTY()
UComposableCameraInertializedTransition* ExitTransition { nullptr };

UPROPERTY()
USplineComponent* InternalSpline;

UPROPERTY()
AActor* DebugSplineActor;

private:
void DrawDebugSplinePoints(const TArray<FVector>& SplinePoints);
void BuildInternalSpline(const FComposableCameraPose& CurrentTargetPose, float DeltaTime);
};

PathGuidedTransition.cpp:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
// Copyright Sulley. All rights reserved.

#include "Transitions/ComposableCameraPathGuidedTransition.h"
#include "Transitions/ComposableCameraInertializedTransition.h"
#include "CameraRig_Rail.h"
#include "ComposableCameraSystemModule.h"
#include "Components/SplineComponent.h"
#include "Core/ComposableCameraPlayerCamaraManager.h"
#include "Kismet/GameplayStatics.h"
#include "Nodes/ComposableCameraSplineNode.h"

void UComposableCameraPathGuidedTransition::OnBeginPlay_Implementation(float DeltaTime,
const FComposableCameraPose& CurrentTargetPose)
{
if (DrivingTransition)
{
DrivingTransition->TransitionEnabled(SourceCamera, TargetCamera, StartCameraPose);
DrivingTransition->SetTransitionTime(TransitionTime);
DrivingTransition->ResetTransitionState();
}

if (RailActor.IsValid())
{
Rail = RailActor.Get();
}

switch (Type)
{
case EComposableCameraPathGuidedTransitionType::Inertialized:
{
// IntermediateCamera
IntermediateCamera = GetWorld()->SpawnActorDeferred<AComposableCameraCameraBase>(AComposableCameraCameraBase::StaticClass(), FTransform{});
IntermediateCamera->bIsRunning = true;
IntermediateCamera->bIsTransient = false;
IntermediateCamera->LifeTime = -1.f;
IntermediateCamera->RemainingLifeTime = -1.f;
IntermediateCamera->Initialize(SourceCamera->GetOwningPlayerCameraManager(), nullptr);

UComposableCameraSplineNode* SplineNode = NewObject<UComposableCameraSplineNode>(IntermediateCamera, UComposableCameraSplineNode::StaticClass());
SplineNode->SplineType = EComposableCameraSplineNodeSplineType::BuiltInSpline;
SplineNode->Rail = Rail;
SplineNode->MoveMethod = EComposableCameraSplineNodeMoveMethod::Automatic;
SplineNode->AutomaticMoveCurve = SplineMoveCurve;
SplineNode->Duration = TransitionTime;
IntermediateCamera->CameraNodes.Add(SplineNode);
IntermediateCamera->FinishSpawning(FTransform{});
IntermediateCamera->Rename(TEXT("PathGuidedTransition_IntermediateCameraOnSpline"));
#if WITH_EDITOR
IntermediateCamera->SetActorLabel(
TEXT("PathGuidedTransition_IntermediateCameraOnSpline"),
false
);
#endif
break;
}
case EComposableCameraPathGuidedTransitionType::Auto:
{

DebugSplineActor = GetWorld()->SpawnActor<AActor>();
USceneComponent* Root = NewObject<USceneComponent>(DebugSplineActor, USceneComponent::StaticClass(), TEXT("RootComponent"));
Root->RegisterComponent();
DebugSplineActor->SetRootComponent(Root);

BuildInternalSpline(CurrentTargetPose, DeltaTime);
DebugSplineActor->Rename(TEXT("PathGuidedTransition_DebugSplineActor"));
#if WITH_EDITOR
DebugSplineActor->SetActorLabel(
TEXT("PathGuidedTransition_DebugSplineActor"),
false
);
#endif

break;
}
}
}

FComposableCameraPose UComposableCameraPathGuidedTransition::OnEvaluate_Implementation(float DeltaTime,
const FComposableCameraPose& CurrentTargetPose)
{
if (!DrivingTransition)
{
UE_LOG(LogComposableCameraSystem, Warning, TEXT("DrivingTransition is not valid in ComposableCameraPathGuidedTransition."));
return CurrentTargetPose;
}
if (!Rail)
{
UE_LOG(LogComposableCameraSystem, Warning, TEXT("SplineActor is not valid in ComposableCameraPathGuidedTransition."));
return CurrentTargetPose;
}

float DurationPct = (GetTransitionTime() - GetRemainingTime()) / GetTransitionTime();

// Base pose.
FComposableCameraPose BasePose = DrivingTransition->Evaluate(DeltaTime, CurrentTargetPose);
FComposableCameraPose ResultPose = BasePose;
Percentage = DrivingTransition->GetPercentage();

switch (Type)
{
case EComposableCameraPathGuidedTransitionType::Inertialized:
{
FComposableCameraPose SplinePose = IntermediateCamera->TickCamera(DeltaTime);
if (DurationPct < GuideRange.X)
{
if (!EnterTransition)
{
EnterTransition = NewObject<UComposableCameraInertializedTransition>(this, UComposableCameraInertializedTransition::StaticClass());
EnterTransition->TransitionEnabled(SourceCamera, IntermediateCamera, SplinePose);
EnterTransition->SetTransitionTime(GuideRange.X * TransitionTime);
EnterTransition->ResetTransitionState();
}

FComposableCameraPose EnterPose = EnterTransition->Evaluate(DeltaTime, SplinePose);
ResultPose.Position = EnterPose.Position;
}
else if (DurationPct <= GuideRange.Y)
{
ResultPose.Position = SplinePose.Position;
}
else
{
if (!ExitTransition)
{
ExitTransition = NewObject<UComposableCameraInertializedTransition>(this, UComposableCameraInertializedTransition::StaticClass());
ExitTransition->TransitionEnabled(IntermediateCamera, TargetCamera, CurrentTargetPose);
ExitTransition->SetTransitionTime(GetTransitionTime() * (1.f - GuideRange.Y));
ExitTransition->ResetTransitionState();
OnTransitionFinishesDelegate.AddLambda(
[InCamera = IntermediateCamera]()
{
if (InCamera)
{
InCamera->Destroy();
}
});
}

FComposableCameraPose ExitPose = ExitTransition->Evaluate(DeltaTime, CurrentTargetPose /*BasePose*/);
ResultPose.Position = ExitPose.Position;
}
break;
}
case EComposableCameraPathGuidedTransitionType::Auto:
{
if (!InternalSpline)
{
UE_LOG(LogComposableCameraSystem, Warning, TEXT("InternalSpline is not valid in ComposableCameraPathGuidedTransition."));
return CurrentTargetPose;
}

const float SplineLen = InternalSpline->GetSplineLength();
const FVector Position = InternalSpline->GetLocationAtDistanceAlongSpline(Percentage * SplineLen, ESplineCoordinateSpace::World);
ResultPose.Position = Position;
break;
}
}

// Draw debug spline.
if (TargetCamera && TargetCamera->GetOwningPlayerCameraManager())
{
if (TargetCamera->GetOwningPlayerCameraManager()->bDrawDebugInformation)
{
DrawDebugSplinePoints(TArray{ ResultPose.Position });
}
}

return ResultPose;
}

void UComposableCameraPathGuidedTransition::DrawDebugSplinePoints(const TArray<FVector>& SplinePoints)
{
for (const FVector& Point : SplinePoints)
{
DrawDebugPoint(GetWorld(), Point, 8.f, FColor::Cyan, false, 2.f, 1.f);
}
}

void UComposableCameraPathGuidedTransition::BuildInternalSpline(const FComposableCameraPose& CurrentTargetPose, float DeltaTime)
{
InternalSpline = DuplicateObject(Rail->GetRailSplineComponent(), DebugSplineActor, TEXT("InternalSplineForPathGuidedTransition"));
InternalSpline->RegisterComponent();
DebugSplineActor->SetActorTransform(Rail->GetActorTransform());
DebugSplineActor->AddInstanceComponent(InternalSpline);
InternalSpline->AttachToComponent(
DebugSplineActor->GetRootComponent(),
FAttachmentTransformRules::KeepRelativeTransform
);
InternalSpline->SetRelativeTransform(FTransform::Identity);

TArray<FSplinePoint> Points;
Points.Reserve(8);

int32 Num = InternalSpline->GetNumberOfSplinePoints();
for (int32 i = 0; i < Num; ++i)
{
Points.Add(
InternalSpline->GetSplinePointAt(i, ESplineCoordinateSpace::Local)
);
}

InternalSpline->ClearSplinePoints(true);

// Prepend and append control points (as long as their tangents)
FVector P0 = Points[1].Position;
FVector P1 = Points[0].Position;
FVector P2 = UKismetMathLibrary::InverseTransformLocation(DebugSplineActor->GetActorTransform(), StartCameraPose.Position);
FVector P3 = UKismetMathLibrary::InverseTransformLocation(DebugSplineActor->GetActorTransform(), SourceCamera->LastFrameCameraPose.Position);

FSplinePoint FirstPoint;
FirstPoint.Position = P2;
FirstPoint.LeaveTangent = (P2 - P3) / DeltaTime;
FirstPoint.ArriveTangent = FirstPoint.LeaveTangent;
FirstPoint.Type = ESplinePointType::CurveCustomTangent;
Points.Insert(FirstPoint, 0);

Num = Points.Num();
P0 = Points[Num - 2].Position;
P1 = Points[Num - 1].Position;
P2 = UKismetMathLibrary::InverseTransformLocation(DebugSplineActor->GetActorTransform(), CurrentTargetPose.Position);
P3 = UKismetMathLibrary::InverseTransformLocation(DebugSplineActor->GetActorTransform(), TargetCamera->LastFrameCameraPose.Position);

FSplinePoint LastPoint;
LastPoint.Position = P2;
LastPoint.ArriveTangent = (P2 - P3) / DeltaTime;
LastPoint.LeaveTangent = LastPoint.ArriveTangent;
LastPoint.Type = ESplinePointType::CurveCustomTangent;
Points.Add(LastPoint);

// Re-add points
for (Num = 0; auto& P : Points)
{
P.InputKey = Num;
InternalSpline->AddPoint(P, false);
++Num;
}

InternalSpline->UpdateSpline();
}

Summary

This article briefly introduced two methods for camera path-guided transitions, achieving the initial requirements. Clearly, there is still much room for optimization, such as:

  1. Smoother movement at the boundaries.
  2. Constructing better movement paths.
  3. Supporting Mutable Source/Target Cameras.

This article serves as a starting point for further discussion, and I do believe improvements can be made to achieve better results and easier use.