UE5.4实现叉车(AGV)按照样条线平滑移动

业务场景:旨在实现叉车(或 AGV 等物流设备)根据给定的路径坐标进行精准移动。后续可通过算法动态传入路径点,并通过 WebSocket 协议实现 UE 客户端与后端的实时通讯。

技术栈:主要基于 UE C++ 实现,结合蓝图进行参数配置与测试。

核心目标:

  1. 叉车能够严格按照指定的样条线(Spline)轨迹移动。
  2. 引入时间轴(Timeline)组件,实现对叉车移动速度的精准且平滑的控制。
  3. 支持动态生成样条线,以适配后端实时下发的路径规划数据。
  4. 通过WebSocket协议将UE与网站部分连接

叉车按照动态生成的样条线移动

我们首先让叉车沿着样条线动起来

前期准备工作

要使用UEC++控制叉车按照样条线,我们首先在UE中创建一个C++类,作为样条线的父类;

再创建一个C++类,作为叉车类的父类,在该C++类中创建叉车的移动等函数,实现叉车的移动

创建样条线Actor

创建样条线的C++类,并创建基于该C++类的蓝图类,在蓝图类中放入样条线组件(Spline Component)注意不是Spline Mesh Component,那是用来依附模型的

image-20260325213116972

image-20260325212644908

image-20260325213151421

image-20260325213610913

创建完毕后,将BP_Spline这个Actor拖入场景中,下面稍微讲解一下如何先手动拉出一条自己想要路径的样条线:

  1. 在场景中,鼠标左键点击那条短线上面的最后一个点(白色的方块点)。选中后,该点会变成高亮的颜色,并且出现常规的移动坐标轴(红绿蓝箭头)。
  2. 按住键盘上的 Alt 键不放
  3. 鼠标左键点击并拖拽坐标轴(比如拖拽红色的X轴,或者在两个轴之间的平面上拖拽)。
  4. 松开鼠标。你会发现,系统自动为你生成了一个新的样条线控制点,并且线条被拉长了!
  5. 重复这个过程:保持选中最后一个点 -> 按住 Alt -> 往外拖拽。你就可以像画画一样,把样条线拉成任意你想要的轨迹。

创建叉车Actor

按照创建样条线相同的方法,我们创建叉车的C++类,并以这个C++类创建一个蓝图类

在蓝图类中摆放叉车的模型

image-20260325214812993

叉车沿着样条线移动

要让叉车“认识”并“跑到”样条线上,我们需要完成以下几个步骤:

  1. 全局搜索:在当前关卡中找到目标样条线 Actor。
  2. 获取组件:从该 Actor 身上提取出包含路径数据的 USplineComponent
  3. 计算距离:获取样条线的总长度,并算出我们想要定位的目标距离。
  4. 空间转换:将这个距离值转换为世界坐标空间中的 3D 坐标点(X, Y, Z)。
  5. 应用位移:把叉车 Actor 的坐标设置过去。

下面给出具体代码:

Forklift.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
// Forklift.h

#pragma once

#include "CoreMinimal.h"
#include "Spline.h"
#include "Components/SplineComponent.h"
#include "GameFramework/Actor.h"
#include "Forklift.generated.h"

UCLASS()
class WMSPRE_API AForklift : public AActor
{
GENERATED_BODY()

public:
// Sets default values for this actor's properties
AForklift();

protected:
// Called when the game starts or when spawned
virtual void BeginPlay() override;

public:
// Called every frame
virtual void Tick(float DeltaTime) override;

// 叉车沿着样条线移动函数
// 暴露给蓝图使用 (BlueprintCallable),可以在蓝图的事件图表中被调用
// Category = "Forklift" 将其归类在蓝图右侧菜单的 Forklift 目录下
UFUNCTION(BlueprintCallable, Category = "Forklift")
void MoveBySpline(float DeltaTime);


private:
// 样条线组件指针:用于获取具体的样条线数据(如位置、切线、长度等)
USplineComponent* SplineComponent;

// 获取样条线
ASpline* SplineRef = nullptr;

// 在头文件 (Forklift.h) 的 private 区域添加:
float CurrentDistanceAlongSpline = 0.0f;
float MovementSpeed = 200.0f; // 叉车移动速度

};

Forklift.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
// Forklift.cpp
// Fill out your copyright notice in the Description page of Project Settings.


#include "Forklift.h"

#include "EngineUtils.h"
#include "Kismet/KismetMathLibrary.h"

// Sets default values
AForklift::AForklift()
{
// Set this actor to call Tick() every frame. You can turn this off to improve performance if you don't need it.
PrimaryActorTick.bCanEverTick = true;

}

// Called when the game starts or when spawned
void AForklift::BeginPlay()
{
Super::BeginPlay();

// 1. 在游戏开始时,只查找一次样条线,节约性能
for (TActorIterator<ASpline> It(GetWorld()); It; ++It)
{
SplineRef = *It;
break; // 找到第一条就立刻跳出循环(如果有多条线,在蓝图里直接指定,而不是这样查)
}

// 2. 预先获取并保存组件的指针,并做安全检查
if (SplineRef != nullptr)
{
SplineComponent = SplineRef->FindComponentByClass<USplineComponent>();
}

}

// Called every frame
void AForklift::Tick(float DeltaTime)
{
Super::Tick(DeltaTime);

// 3. 每帧调用移动逻辑,并传入 DeltaTime 保证平滑
MoveBySpline(DeltaTime);

}

void AForklift::MoveBySpline(float DeltaTime)
{
// 4. 终极安全检查:如果组件不存在,直接退出函数,防止崩溃
if (SplineComponent == nullptr)
{
return;
}

// 5. 让距离随时间增加 (速度 * 时间 = 距离)
CurrentDistanceAlongSpline += MovementSpeed * DeltaTime;

// 获取样条线总长度,让叉车到达终点后循环或停止
float SplineLength = SplineComponent->GetSplineLength();
if (CurrentDistanceAlongSpline > SplineLength)
{
CurrentDistanceAlongSpline = 0.0f; // 回到起点循环,或者可以做其他处理
}

// 6. 根据当前距离获取坐标
FVector NewLocation = SplineComponent->GetLocationAtDistanceAlongSpline(
CurrentDistanceAlongSpline,
ESplineCoordinateSpace::World
);

// 7. 设置新位置
SetActorLocation(NewLocation);

}

在实现上述基础坐标定位的过程中,我们调用了几个 Unreal Engine 5 中非常关键的底层机制和数学函数,我们先了解几个UE5中的基础函数:

  • 全局 Actor 遍历 (TActorIterator): 在代码中,我们使用了 TActorIterator<ASpline>(GetWorld())。这是 UE C++ 中用于在当前关卡(World)中查找特定类(及其子类)所有实例的标准迭代器。技术提示:这个操作会扫描场景中的大量对象,性能开销较大,因此在实际开发中,通常只在 BeginPlay 中执行一次,或者直接通过暴露变量到编辑器中进行引用赋值,严禁将其放在每帧执行的 Tick 函数中。
  • **组件获取 (FindComponentByClass)**: Actor 本身只是一个容器,真正的路径数据存储在它的组件里。通过 FindComponentByClass<USplineComponent>(),我们能够安全地提取出控制路径走向的核心组件,进而读取它的数据。
  • **样条线核心 API (GetLocationAtDistanceAlongSpline)**: 这是样条线系统的“灵魂函数”。它接收一个一维的距离值(Distance),并将其投射到弯曲的 3D 样条线上,最终返回一个精确的 3D 世界坐标(FVector)。我们在传参时指定了 ESplineCoordinateSpace::World,确保返回的坐标是绝对的世界坐标,这样叉车才能正确到位。
  • **坐标应用 (SetActorLocation)**: 最后,我们将计算出的目标坐标直接赋予叉车。由于缺少时间的过渡,这个函数在当前表现为“瞬间传送”。

在上面的准备工作中,我们在 C++ 的 Tick 函数中,通过 DeltaTime 手动累加移动距离来实现叉车的位移。这种“生硬计算”虽然能让模型动起来,但面临三个致命缺陷:

  1. 无法变速:难以实现真实的起步加速和到站刹车减速。
  2. 状态难控:暂停、倒车或从指定中途点出发的逻辑编写极度复杂。
  3. 帧率依赖:容易因设备卡顿导致物理表现不同步。

为此,我们通过 C++ 封装一个标准的 时间轴组件(Timeline Component),作为所有物流设备的“动力核心”。我们将通过 C++ 绑定一个 Timeline 组件,利用它输出的平滑曲线(Curve)来动态驱动插值的 Alpha 值,从而让叉车真正拥有起步、匀速、停车的物理表现

使用时间轴(Timeline)驱动叉车的移动

创建浮点曲线(Curve Float)

首先在 UE 引擎中,右键新建 -> 其他 -> 曲线 -> 选择 Curve Float

关键帧设置:添加两个关键帧,起点为 **(0, 0)**,终点为 **(1, 1)**。这条 1 秒长、从 0 渐变到 1 的直线段,将作为我们后续换算路程的基础比例尺。

image-20260326202821012

image-20260326202936112

编写自定义时间轴组件 (UMyTimeLineComponent)

为了方便以后复用,我们将Timeline组件写成一个标准的组件(继承自UActorComponent),后续我们将其绑定在穿梭车、AGV等复用。

创建一个继承自 UActorComponent 的 C++ 类,方便挂载到任何 Actor 上。

image-20260326202332734

源文件 (MyTimeLineComponent.cpp) 避坑指南:

  • 性能优化:时间轴底层自带 Tick 计算,务必在构造函数中设置 PrimaryComponentTick.bCanEverTick = false; 关闭当前组件的额外 Tick。
  • 事件绑定:利用引擎反射机制,将蓝图中配置的浮点曲线(MovementCurve)绑定到 OnTimelineUpdate 函数上,并在函数内部通过多播委托OnTimelineUpdateDelegate.Broadcast(Value) 将 0~1 的进度值广播出去。

MyTimeLineComponent的具体代码:

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
// MyTimeLineComponent.h

#pragma once

#include "CoreMinimal.h"
#include "Components/ActorComponent.h"
#include "Components/TimelineComponent.h"
#include "MyTimeLineComponent.generated.h"

// 声明动态多播委托,方便 C++ 订阅和蓝图调用
DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FOnTimelineUpdateSignature, float, Value);
DECLARE_DYNAMIC_MULTICAST_DELEGATE(FOnTimelineFinishedSignature);


UCLASS( ClassGroup=(Custom), meta=(BlueprintSpawnableComponent) )
class WMSPRE_API UMyTimeLineComponent : public UActorComponent
{
GENERATED_BODY()

public:
// Sets default values for this component's properties
UMyTimeLineComponent();

protected:
// Called when the game starts
virtual void BeginPlay() override;

public:
// Called every frame
virtual void TickComponent(float DeltaTime, ELevelTick TickType, FActorComponentTickFunction* ThisTickFunction) override;

UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Timeline")
UCurveFloat* MovementCurve;

// 广播更新值的委托
UPROPERTY(BlueprintAssignable, Category = "Timeline")
FOnTimelineUpdateSignature OnTimelineUpdateDelegate;

// 广播完成事件的委托
UPROPERTY(BlueprintAssignable, Category = "Timeline")
FOnTimelineFinishedSignature OnTimelineFinishedDelegate;

// 暴露给外部(蓝图/C++)的控制接口
UFUNCTION(BlueprintCallable, Category = "Timeline")
void PlayTimeline();

UFUNCTION(BlueprintCallable, Category = "Timeline")
void SetPlayRate(float NewRate);

private:
// 底层真正的 UE 时间轴组件
UPROPERTY()
UTimelineComponent* InternalTimeline;

// 内部绑定的回调函数
UFUNCTION()
void OnTimelineUpdate(float Value);

UFUNCTION()
void OnTimelineFinished();

};
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
// MyTimeLineComponent.cpp


#include "MyTimeLineComponent.h"
#include "Components/TimelineComponent.h"

// Sets default values for this component's properties
UMyTimeLineComponent::UMyTimeLineComponent()
{
// 组件本身不需要 Tick,UTimelineComponent 底层会自动 Tick,节省性能
PrimaryComponentTick.bCanEverTick = false;

// 实例化内部的 Timeline 组件
InternalTimeline = CreateDefaultSubobject<UTimelineComponent>(TEXT("InternalTimeline"));
}


// Called when the game starts
void UMyTimeLineComponent::BeginPlay()
{
Super::BeginPlay();

// 在蓝图里配置曲线
if (MovementCurve)
{
// 绑定 Update 逻辑
FOnTimelineFloat UpdateFunction;
UpdateFunction.BindUFunction(this, FName("OnTimelineUpdate"));
InternalTimeline->AddInterpFloat(MovementCurve, UpdateFunction);

// 绑定 Finished 逻辑
FOnTimelineEvent FinishedFunction;
FinishedFunction.BindUFunction(this, FName("OnTimelineFinished"));
InternalTimeline->SetTimelineFinishedFunc(FinishedFunction);
}
else
{
// 报错提示
UE_LOG(LogTemp, Error, TEXT("[%s] MovementCurve 为空!请在蓝图详情面板中为其指定一条浮点曲线。"), *GetName());
}

}

// Called every frame
void UMyTimeLineComponent::TickComponent(float DeltaTime, ELevelTick TickType, FActorComponentTickFunction* ThisTickFunction)
{
Super::TickComponent(DeltaTime, TickType, ThisTickFunction);

// ...
}

void UMyTimeLineComponent::OnTimelineUpdate(float Value)
{
// 向外广播当前的值,叉车等设备会收到这个值
OnTimelineUpdateDelegate.Broadcast(Value);
}

void UMyTimeLineComponent::OnTimelineFinished()
{
// 向外广播完成事件
OnTimelineFinishedDelegate.Broadcast();
}

void UMyTimeLineComponent::PlayTimeline()
{
if (InternalTimeline)
{
InternalTimeline->PlayFromStart(); // 每次调用都从头播放,根据业务需求也可以改成 Play()
}
}

void UMyTimeLineComponent::SetPlayRate(float NewRate)
{
if (InternalTimeline)
{
InternalTimeline->SetPlayRate(NewRate);
}
}

注意不要忘记在时间轴的蓝图中绑定曲线MovementCurve

要使用新增的时间轴组件,我们要在叉车的文件中修改相关的代码。

Forklift.h 中:

1
2
3
4
5
6
7
8
9
#include "Component/MyTimelineComponent.h" // 跨文件夹引用加前缀

// 在类中声明组件指针
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Components")
UMyTimelineComponent* TimelineComp;

// 声明一个接收时间轴更新的函数
UFUNCTION()
void HandleTimelineUpdate(float Value);

Forklift.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
// Fill out your copyright notice in the Description page of Project Settings.

#include "Forklift.h"
#include "EngineUtils.h"
#include "Kismet/KismetMathLibrary.h"

// Sets default values
AForklift::AForklift()
{
// 优化 1:既然有了时间轴驱动,我们就不再需要每一帧 Tick 了,关闭它以大幅节省性能
PrimaryActorTick.bCanEverTick = false;

// 创建时间轴组件并挂载
TimelineComp = CreateDefaultSubobject<UMyTimeLineComponent>(TEXT("TimelineComp"));
}

// Called when the game starts or when spawned
void AForklift::BeginPlay()
{
Super::BeginPlay();

// 在游戏开始时,只查找一次样条线
for (TActorIterator<ASpline> It(GetWorld()); It; ++It)
{
SplineRef = *It;
break;
}

// 获取样条线组件
if (SplineRef != nullptr)
{
SplineComponent = SplineRef->FindComponentByClass<USplineComponent>();
}

// 绑定时间轴的更新委托
if (TimelineComp)
{
TimelineComp->OnTimelineUpdateDelegate.AddDynamic(this, &AForklift::HandleTimelineUpdate);

// 一开始叉车就自动沿着样条线开
TimelineComp->PlayTimeline();
}
}

// 💡 优化 2:彻底删除了 Tick 和原先手写的 MoveBySpline,全部交给时间轴接管

void AForklift::HandleTimelineUpdate(float Value)
{
// 安全检查:如果没找到样条线,直接退出防止崩溃
if (SplineComponent == nullptr)
{
return;
}

// 核心逻辑:
// 假设您的时间轴曲线 (Curve) 的 Y 轴值是从 0.0 (起点) 变到 1.0 (终点)。
// 我们用这个比例值乘以样条线的总长度,就能得出当前应该在的具体距离。

float SplineLength = SplineComponent->GetSplineLength();
float CurrentDistance = SplineLength * Value;

// 1. 获取样条线在该距离下的【位置】
FVector NewLocation = SplineComponent->GetLocationAtDistanceAlongSpline(
CurrentDistance,
ESplineCoordinateSpace::World
);

// 2. 获取样条线在该距离下的【旋转/朝向】(让叉车能顺着轨道转弯,而不是平移)
FRotator NewRotation = SplineComponent->GetRotationAtDistanceAlongSpline(
CurrentDistance,
ESplineCoordinateSpace::World
);

// 3. 同时更新叉车的位置和朝向
SetActorLocationAndRotation(NewLocation, NewRotation);

}

这样我们就实现了通过时间轴来驱动叉车进行移动,不过此时叉车的移动速度过快且无法控制。时间轴本质控制的是“完成整个路程所需的时间”,而不是直接控制“速度”。因此,在使用时间轴时,我们控制叉车速度的终极武器是:动态修改时间轴的播放速率(Play Rate)。

通过时间轴修改叉车移动的速度

基于 1.0 秒的基准曲线,我们可以使用以下物理公式:

  1. 所需总时间

    Time = SplineLength/TargetSpeed

    (样条线总长度/目标速度)

  2. 时间轴播放速率

    PlayRate = 1.0/Time

  3. 合并化简后得出最终公式

    PlayRate = TargetSpeed/SplineLength

举个例子

假设样条线全长 1000 cm,您希望叉车的速度是 200 cm/s。

按照公式:PlayRate = 200 / 1000 = 0.2。

这意味着时间轴会以 0.2 倍速播放,原本 1 秒播完的曲线,现在需要 5 秒播完。正好符合 1000 /200 = 5 s。

我们现在修改对应的C++文件

修改 Forklift.h

添加一个用于控制速度的变量

1
2
3
4
5
6
7
8
9
public:
// 叉车的移动速度 (单位:厘米/秒, UE默认单位是cm)
// 假设设定为 300.0f,即 3米/秒
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Forklift Setup")
float MovementSpeed = 300.0f;

// 启动叉车沿着样条线移动的函数
UFUNCTION(BlueprintCallable, Category = "Forklift Action")
void StartMovingOnSpline();

修改 Forklift.cpp

实现计算播放速率并启动时间轴的逻辑写在一个专门启动移动的函数(StartMovingOnSpline

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
void AForklift::StartMovingOnSpline()
{
// 1. 安全检查
if (SplineComponent == nullptr || TimelineComp == nullptr)
{
UE_LOG(LogTemp, Error, TEXT("无法移动:样条线或时间轴组件为空!"));
return;
}

// 2. 获取样条线总长度
float SplineLength = SplineComponent->GetSplineLength();

if (SplineLength <= 0.0f) return; // 防止除以0的崩溃

// 3. 核心计算:目标速度 / 总长度
float CalculatedPlayRate = MovementSpeed / SplineLength;

// 4. 将计算出的播放速率设置给时间轴组件
// (这里调用了我们之前在 UMyTimeLineComponent 中写好的 SetPlayRate 方法)
TimelineComp->SetPlayRate(CalculatedPlayRate);

// 5. 开始播放时间轴
TimelineComp->PlayTimeline();

UE_LOG(LogTemp, Warning, TEXT("叉车出发!速度: %f cm/s, 播放速率: %f"), MovementSpeed, CalculatedPlayRate);
}

但是此时叉车的移动依然是靠着提前在场景中放好的样条线来驱动的,而真实的场景中,叉车的路径不是固定的,单独一个样条线也无法驱动所有叉车运动,因此接下来我们选择动态生成样条线。

动态生成样条线

在真实 WMS 场景下,叉车不可能依赖场景中预设的线条。我们需要赋予叉车“自己画路自己走”的能力。

这个方法可以接受到后端发来的一组路径规划的坐标等,并根据这些坐标,动态的在自己身上生成一条包含这些点的临时样条线,然后驱动叉车沿着这条临时线开。当叉车抵达终点后,该临时样条线被销毁。

实现这个功能,我们需要做两个事情:

  1. 让每个叉车自带一个USplineComponent(注意:是记录路径的样条线组件,不是附着网格体的 SplineMeshComponent)。
  2. 强制将该样条线设置为“绝对世界坐标”,否则样条线会跟着叉车一起移动,无法起到导航的效果

修改 Forklift.h

我们需要在叉车身上添加属于它自己的样条线组件,并暴露一个接收点位数组的函数 MoveAlongPath

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
#pragma once

#include "CoreMinimal.h"
#include "GameFramework/Actor.h"
#include "Components/SplineComponent.h"
#include "Component/MyTimeLineComponent.h"
#include "Forklift.generated.h"

UCLASS()
class WMSPRE_API AForklift : public AActor
{
GENERATED_BODY()

public:
AForklift();

protected:
virtual void BeginPlay() override;

public:
// 叉车的根组件 (建议在蓝图里把叉车模型挂在这个下面)
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Components")
USceneComponent* RootComp;

// 专属时间轴组件
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Components")
UMyTimeLineComponent* TimelineComp;

// 每辆叉车专属的“独立样条线”组件
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Components")
USplineComponent* PathSpline;

// 叉车移动速度 (cm/s)
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Forklift Setup")
float MovementSpeed = 300.0f;

// 核心接口:接收后端传来的坐标点,生成路径并开始移动
UFUNCTION(BlueprintCallable, Category = "Forklift Action")
void MoveAlongPath(const TArray<FVector>& PathPoints);

private:
// 时间轴更新回调
UFUNCTION()
void HandleTimelineUpdate(float Value);
};

修改 Forklift.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
#include "Forklift.h"

AForklift::AForklift()
{
PrimaryActorTick.bCanEverTick = false;

// 1. 初始化根组件
RootComp = CreateDefaultSubobject<USceneComponent>(TEXT("RootComp"));
RootComponent = RootComp;

// 2. 初始化时间轴组件
TimelineComp = CreateDefaultSubobject<UMyTimeLineComponent>(TEXT("TimelineComp"));

// 3. 初始化专属样条线组件
PathSpline = CreateDefaultSubobject<USplineComponent>(TEXT("PathSpline"));
PathSpline->SetupAttachment(RootComponent);

// 极其关键的代码:设置样条线使用绝对世界坐标!
// 这样当叉车移动时,样条线会牢牢钉在世界上,不会跟着叉车乱跑。
PathSpline->SetUsingAbsoluteLocation(true);
PathSpline->SetUsingAbsoluteRotation(true);
}

void AForklift::BeginPlay()
{
Super::BeginPlay();

// 只需要绑定时间轴更新,不再需要在场景里到处找样条线了!
if (TimelineComp)
{
TimelineComp->OnTimelineUpdateDelegate.AddDynamic(this, &AForklift::HandleTimelineUpdate);
}
}

// 通过自定义样条点生成样条线,驱动调用叉车移动的核心逻辑
void AForklift::MoveAlongPath(const TArray<FVector>& PathPoints)
{
// 如果点位少于2个,无法构成线,直接退出
if (PathPoints.Num() < 2 || PathSpline == nullptr || TimelineComp == nullptr)
{
UE_LOG(LogTemp, Warning, TEXT("点位不足或组件丢失,无法移动!"));
return;
}

// 1. 清空上一段任务的旧路线
PathSpline->ClearSplinePoints();

// 2. 遍历传入的坐标点,动态生成新样条线
for (int32 i = 0; i < PathPoints.Num(); ++i)
{
// 将点添加到世界坐标系中,false 表示暂时不更新样条线(为了性能,全加完再更新)
PathSpline->AddSplinePoint(PathPoints[i], ESplineCoordinateSpace::World, false);

// 将点的类型设为直线 (Linear)。
// AGV/叉车在仓库通常是走直角或标准的圆弧转弯,如果不设为 Linear,UE默认的曲线可能会让车在路口“飘移”越界。
PathSpline->SetSplinePointType(i, ESplinePointType::Linear, false);
}

// 3. 所有的点都加完后,统一更新一次样条线内部数据
PathSpline->UpdateSpline();

// 4. 获取新生成的路线总长度
float SplineLength = PathSpline->GetSplineLength();
if (SplineLength > 0.0f)
{
// 5. 动态计算播放速率 (PlayRate) = 速度 / 长度
float PlayRate = MovementSpeed / SplineLength;
TimelineComp->SetPlayRate(PlayRate);

// 6. 出发
TimelineComp->PlayTimeline();

UE_LOG(LogTemp, Warning, TEXT("叉车收到新路线!共 %d 个点,路线长度: %f, 速度: %f"), PathPoints.Num(), SplineLength, MovementSpeed);
}
}

void AForklift::HandleTimelineUpdate(float Value)
{
if (PathSpline == nullptr) return;

// 根据进度 Alpha 值 (0.0 ~ 1.0),计算当前应该在样条线上的距离
float SplineLength = PathSpline->GetSplineLength();
float CurrentDistance = SplineLength * Value;

// 获取坐标和朝向
FVector NewLocation = PathSpline->GetLocationAtDistanceAlongSpline(CurrentDistance, ESplineCoordinateSpace::World);
FRotator NewRotation = PathSpline->GetRotationAtDistanceAlongSpline(CurrentDistance, ESplineCoordinateSpace::World);

// 移动叉车本身
SetActorLocationAndRotation(NewLocation, NewRotation);
}

测试

上面代码完成后,我们可以通过在蓝图中绑定按键输入触发移动函数,并手动设置一些坐标来驱动叉车的移动

  1. 打开关卡蓝图
  2. 在蓝图的事件图表(Event Graph)中,找一个测试按键(比如键盘 1)。
  3. 使用 Make Array 节点构建一个包含 3-4 个 Vector 坐标的数组(例如:起点 (0,0,0) -> 途经点 (1000,0,0) -> 转弯点 (1000,1000,0))。
  4. 将这个数组连入我们刚刚写好的 Move Along Path 节点。
  5. 运行游戏,按下 1,叉车就会开始移动

image-20260326205258434

至此我们已经实现了叉车按照动态生成的样条线进行移动,并暴露了接口 MoveAlongPath(const TArray<FVector>& PathPoints) 供后端数据调用。

那我们来总结一下我们到底具体干了什么

前置原型阶段(最初的静态测试)

在引入复杂机制前,最初的版本仅仅是为了让叉车“动起来”。

  • 实现了什么:在场景中手动拉出一条 BP_Spline 线,然后在叉车的 Tick 函数中,通过 DeltaTime * 速度 每帧手动累加行驶距离。

第一次迭代:引入时间轴 (Timeline)

为了解决无法变速、难于控制等物理表现生硬的问题,我们重构了驱动核心。

  • 新增了什么:创建了独立且可复用的 C++ 组件 UMyTimeLineComponent,利用一条 0.0 到 1.0 的 CurveFloat(浮点曲线)来输出移动进度。
  • 舍弃了什么:彻底关闭了叉车类的每帧更新(PrimaryActorTick.bCanEverTick = false),删除了在 Tick 函数中手动累加距离的代码。
  • 为什么要这么做
    1. 摆脱帧率绑架:纯代码在 Tick 中累加位移,会受到电脑配置和游戏掉帧的影响。
    2. 动作扩展性:手动累加极难处理变速运动(如起步加速、到站减速),且很难实现随时暂停、倒车等状态控制。时间轴完美解决了这些物理平滑过渡的需求。

第二次迭代:速度换算 (PlayRate 控制)

有了时间轴后,我们需要解决“距离变长,速度变快”的谬误。

  • 新增了什么:在移动执行前,加入了一套数学换算逻辑:CalculatedPlayRate = MovementSpeed / SplineLength,以此动态修改时间轴的 PlayRate(播放速率)。
  • 舍弃了什么:舍弃了时间轴默认的“固定时间播完全程”的特性(即 1 秒钟跑完曲线)。
  • 为什么要这么做:时间轴本质上控制的是“耗时”而非“速度”。如果不加以干预,叉车走 10 米和走 1000 米都会在 1 秒内完成,这显然违背物理常识。通过路程和目标速度反推播放速率,我们保证了无论路线多长,叉车永远以设定的真实物理速度(如 300 cm/s)匀速行驶。

第三次迭代:动态生成专属样条线

为了适配真实的仓储业务,我们让叉车具备了“自行铺路”的能力。

  • 新增了什么:给每一辆叉车 Actor 安装了专属USplineComponent,并强制设为绝对世界坐标。暴露出 MoveAlongPath 接口,接收 TArray<FVector> 数组,动态绘制点位类型为直线(Linear)的路径。
  • 舍弃了什么
    1. 舍弃了在场景中手工预设画好的静态样条线。
    2. 彻底删除了 BeginPlay 中极其耗费性能的 TActorIterator<ASpline> 全局遍历查找代码。
  • 为什么要这么做
    1. 性能痛点TActorIterator 会扫描场景中的大量对象,性能开销极大,不适合作为常规逻辑。
    2. 业务刚需:真实的 WMS 场景中,成百上千台 AGV/叉车的路径是后端算法实时动态规划的。固定的路线无法应对拥堵绕行、随时变道的需求。将样条线内置到叉车身上,做到了真正的“数据驱动模型”。