最近在工作上接到一個需求,是要幫策劃提供配置 Npc 巡邏路線的流程。最開始因為策劃那邊沒有特別複雜的要求,只要能在不同點之間巡邏就行,所以我先做了一版最直觀的方案:在場景裡先放好幾個點,然後讓 Npc 根據一組 Actor 列表,依照順序模式 1 -> 2 -> 3 -> 1 -> 2 -> 3,或者 YoYo 模式 1 -> 2 -> 3 -> 2 -> 1,在這些巡邏點之間來回移動。

最初的做法

這個方法雖然能用,跑起來也沒什麼問題,但後續給策劃配置時其實很不方便。比如在 UE 的 Outliner 裡面選一堆 Actor,並不是一種很理想的工作流;而且 Npc 上用來存巡邏點的列表變量,也不像 Unity 那樣可以直接把多個對象一起拖進去,很多時候還是得一個一個設。

另一個問題是,這種配置方式本質上只適合做點到點的直線移動,很難做出沿著彎道前進的感覺。所以後來 Leader 就讓我去找找看,能不能借用類似道路、河流那一套曲線工具,來處理這類巡邏路線的配置。

為什麼改用 Spline

最後我很快就想到可以試 Spline。Spline 的核心能力,本來就是在場景中放多個點,再根據點和點之間的 tangent 值自動生成曲線段。除了可以直接拖動控制點、用 Alt 快速拉出新點之外,它還能提供幾個這次剛好很需要的接口:

  • 取得某個 Spline Point 的位置
  • 取得某個 Spline Point 對應的累積距離
  • 根據距離反推出曲線上的具體 Location

最重要的是,它會把整條路線直接可視化顯示出來,對配置和調試都非常友好。

所以我建了一個 Actor 容器,加上一個 Spline 組件,再隨手放幾個點,效果大概就會像這樣:

接著我透過 SplineComponent.GetLocationAtSplinePoint(Index, SpaceType) 取得每個巡邏點的世界座標,再把這些點帶回原本點到點的移動方案裡,就先完成了最基本的 Spline 巡邏配置。

FVector AYLSplinePath::GetPatrolPointLocationByIndex(
	int Index,
	ESplineCoordinateSpace::Type SpaceType
)
{
	ConfirmSplineComp();
	if (SplineComp)
	{
		int SplinePointCount = SplineComp->GetNumberOfSplinePoints();
		if (Index >= 0 && Index < SplinePointCount)
		{
			return SplineComp->GetLocationAtSplinePoint(Index, SpaceType);
		}
	}

	return FVector::ZeroVector;
}

沿著曲線移動

但既然都已經有了完整的路線,單純直線移動就不太夠用了。Spline 雖然能提供每個點的位置,卻沒有直接幫你把 Actor 沿著某一段曲線平滑移動的現成接口,所以這一段還是得自己補。

我當時大致整理出兩種做法:

  1. Timeline,讓 Actor 在相鄰兩個點之間做插值移動。
  2. 把相鄰兩點之間切成多個子線段,再讓 Actor 依序移動這些子線段上的位置。

參考資料

第一種方法有點太重,而且會綁住 Timeline。我這裡做的是 Npc 的巡邏邏輯,中途可能隨時被打斷、切回其他行為,不是那種只要一路循環播完就好的單純演出,所以最後我選的是第二種思路。

平均切分的問題

但在第二種做法裡,還有一個關鍵問題:子線段要怎麼切,才能看起來不像在折線上跳點?

如果只是用平均分布的方式:

FVector FirstLocation = GetPatrolPointLocationByIndex(PrevIndex, SpaceType);
FVector NextLocation = GetPatrolPointLocationByIndex(CurrIndex, SpaceType);
float Distance1 = SplineComp->GetDistanceAlongSplineAtSplinePoint(PrevIndex);
float Distance2 = SplineComp->GetDistanceAlongSplineAtSplinePoint(CurrIndex);
float DistanceDiff = Distance2 - Distance1;
float Step = DistanceDiff / SplitCount;
TArray<FVector> Results;

for (int i = 0; i < SplitCount; i++)
{
	FVector TargetPos = SplineComp->GetLocationAtDistanceAlongSpline(
		Distance1 + Step * (i + 1),
		SpaceType
	);
	Results.Add(TargetPos);
}

那麼在兩個控制點之間比較直的區段,效果其實還不錯;但如果來到曲線起點和終點附近,也就是彎曲最明顯的地方,就還是很容易看出直線逼近曲線的痕跡。控制點距離越遠、切分數量越少,這個問題就越明顯,因為第一段和最後一段子線段,很可能沒有把頭尾的彎曲區域覆蓋好。

用 SmoothStep 改善分布

為了讓切分更集中在起點和終點附近,我後來找到 SmoothStep 這個常見的 S 型函數:

在我的情況裡,可以先把 i / (SplitCount - 1) 當成 01 之間的 Alpha,再透過 3x^2 - 2x^3 去重新映射。這樣一來,越靠近起點和終點的位置,子點分布就會越密;中間區段則相對放鬆,整體看起來會更貼近真實曲線的彎曲方式。

我最後的做法大致如下:

TArray<FVector> AYLSplinePath::SplitMidPointsBetweenPatrolPoints(
	int PrevIndex,
	int CurrIndex,
	int SplitCount,
	ESplineCoordinateSpace::Type SpaceType
)
{
	ConfirmSplineComp();
	TArray<FVector> Result;

	if (SplineComp && SplitCount > 1)
	{
		int SplinePointCount = SplineComp->GetNumberOfSplinePoints();
		if (
			PrevIndex >= 0 && PrevIndex < SplinePointCount &&
			CurrIndex >= 0 && CurrIndex < SplinePointCount
		)
		{
			FVector FirstLocation = GetPatrolPointLocationByIndex(PrevIndex, SpaceType);
			FVector NextLocation = GetPatrolPointLocationByIndex(CurrIndex, SpaceType);
			float Distance1 = SplineComp->GetDistanceAlongSplineAtSplinePoint(PrevIndex);
			float Distance2 = SplineComp->GetDistanceAlongSplineAtSplinePoint(CurrIndex);
			TArray<float> NormalizedDistances;

			for (int i = 0; i < SplitCount; ++i)
			{
				float Alpha = static_cast<float>(i) / static_cast<float>(SplitCount - 1);
				Alpha = 3 * FMath::Pow(Alpha, 2) - 2 * FMath::Pow(Alpha, 3);
				NormalizedDistances.Add(Alpha);

				float Distance = FMath::Lerp(Distance1, Distance2, Alpha);
				FVector Location = SplineComp->GetLocationAtDistanceAlongSpline(
					Distance,
					SpaceType
				);
				Result.Add(Location);
			}

			Result.Insert(FirstLocation, 0);
			Result.Add(NextLocation);
		}
	}

	return Result;
}

最後,這是 SplitCount = 16 時的效果: