[Sebastian Lague] A* Pathfinding (E03: algorithm implementation)
카테고리: Techniques
이 포스팅은 Sebastian Lague의 A* Pathfinding 영상을 기반으로 진행됩니다.
깃허브 주소: Sebistian Lague’s Github
📦 구현
이전 pseudo 코드를 사용하여 A* 알고리즘을 구현할 것이다.
참고로, 저번 코드들에 포인터를 붙이는 등 수정사항이 많다.
📜 Pathfinding.h
// Fill out your copyright notice in the Description page of Project Settings.
#pragma once
#include "CoreMinimal.h"
#include "GameFramework/Actor.h"
#include "Pathfinding.generated.h"
struct FNode;
class AGrid;
UCLASS()
class SL_ASTAR_API APathfinding : public AActor
{
GENERATED_BODY()
public:
// Sets default values for this actor's properties
APathfinding();
// 레벨에 있는 Grid를 참조하기 위한 변수.
// Grid.h의 UCLASS 안에 BlueprintType을 넣어줘야 작동함.
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Pathfinding")
AGrid* Grid;
// 시작점
UPROPERTY(EditAnywhere)
AActor* Seeker;
// 도착점
UPROPERTY(EditAnywhere)
AActor* Target;
protected:
// Called when the game starts or when spawned
virtual void BeginPlay() override;
public:
// Called every frame
virtual void Tick(float DeltaTime) override;
// 경로 찾기
void FindPath(const FVector& StartPos, const FVector& TargetPos);
// H값(현재 노드 - 도착 노드) 거리 구하는 함수
int GetDistance(FNode* NodeA, FNode* NodeB);
// FindPath 함수가 실행된 후, 최종 경로를 구하는 함수
void RetracePath(FNode* StartNode, FNode* EndNode);
};
에디터 상에서 Seeker와 Target 역할을 해줄 임의 Actor를 만들고,
블루프린트 변수에 넣어주면 된다.
📜 Pathfinding.cpp
FindPath() 파트
void APathfinding::FindPath(const FVector& StartPos, const FVector& TargetPos) {
FNode* StartNode = Grid->NodeFromWorldPoint(StartPos);
FNode* TargetNode = Grid->NodeFromWorldPoint(TargetPos);
TArray<FNode*> OpenSet;
TSet<FNode*> ClosedSet;
OpenSet.Emplace(StartNode);
while (OpenSet.Num() > 0) {
// OpenSet에서 FCost가 가장 작으면서, HCost 또한 작은 노드를 구함.
FNode* CurrentNode = OpenSet[0];
for (int i = 0; i < OpenSet.Num(); i++) {
if (
OpenSet[i]->FCost() < CurrentNode->FCost() ||
(OpenSet[i]->FCost() == CurrentNode->FCost() && OpenSet[i]->HCost < CurrentNode->HCost)
) {
CurrentNode = OpenSet[i];
}
}
OpenSet.Remove(CurrentNode);
ClosedSet.Emplace(CurrentNode);
// 목적지에 도달한 경우
if (CurrentNode == TargetNode) {
// 최종 경로 찾기
RetracePath(StartNode, TargetNode);
return;
}
// 현재 노드에서 모든 이웃 검색
for (FNode* Neighbour : Grid->GetNeighbours(CurrentNode)) {
// 이웃 노드가 장애물이거나, ClosedSet에 속한 경우 continue
if (!Neighbour->bWalkable || ClosedSet.Contains(Neighbour)) continue;
// 한번도 연산된적 없는 노드거나, 연산됐었지만 새로운 경로가 더 빠른 경우
int NewMovementCostToNeighbour = CurrentNode->GCost + GetDistance(CurrentNode, Neighbour);
if (NewMovementCostToNeighbour < Neighbour->GCost || !OpenSet.Contains(Neighbour)) {
Neighbour->GCost = NewMovementCostToNeighbour;
Neighbour->HCost = GetDistance(Neighbour, TargetNode);
Neighbour->Parent = CurrentNode;
// 연산된적 없는 노드였다면 OpenSet에 추가
if (!OpenSet.Contains(Neighbour)) OpenSet.Emplace(Neighbour);
}
}
}
}
👉🏻 특징
- Emplace()와 Add()
- Emplace는 참조 변수를 추가하는 것이고, Add는 변수를 복사하여 추가하는 것으로 다르다.
최적화를 위해선 알맞은 곳에 Emplace를 사용하는 것이 좋다.
- Emplace는 참조 변수를 추가하는 것이고, Add는 변수를 복사하여 추가하는 것으로 다르다.
- 포인터
- 유니티의 클래스는 참조 형식, 구조체는 값 형식이다.
- 언리얼은 클래스와 구조체 모두 값 형식으로 구현되어있다.
- Sebastian Lague은 유니티를 쓰고, Node는 클래스로 구현되었으므로, 변수가 복사되는 일이 없었다.
하지만 언리얼을 쓰는 상황에서 Neighbour와 Parent가 똑같은 객체를 가리키게 하기 위해선 포인터를 사용해야 했다.
GetDistance() 파트
// 두 노드 간의 거리
int APathfinding::GetDistance(FNode* NodeA, FNode* NodeB) {
int DstX = abs(NodeA->GridX - NodeB->GridX);
int DstY = abs(NodeA->GridY - NodeB->GridY);
if (DstX > DstY)
return 14 * DstY + 10 * (DstX - DstY);
return 14 * DstX + 10 * (DstY - DstX);
}
👉🏻 특징
- 맞닿은 거리는 10, 대각선 거리는 14로 치고 계산하고 있다.
RetracePath() 파트
// 최종 경로 탐색 함수
void APathfinding::RetracePath(FNode* StartNode, FNode* EndNode) {
Grid->Path.Empty();
FNode* CurrentNode = EndNode;
while (CurrentNode != StartNode) {
Grid->Path.Emplace(CurrentNode);
if (!CurrentNode->Parent) {
UE_LOG(LogTemp, Warning, TEXT("Parent is nullptr"));
return;
}
CurrentNode = CurrentNode->Parent;
}
Grid->Path.Emplace(StartNode);
Algo::Reverse(Grid->Path);
}
👉🏻 특징
- TList 대신 TArray
- 유니티에는 리스트가 잘 구현되어 있지만, 언리얼에는 리스트의 기능이 거의 없다시피 하다.
TList보단 TArray로 구현하는 것을 추천하는 경우가 많아, TArray로 구현하였다.
- 유니티에는 리스트가 잘 구현되어 있지만, 언리얼에는 리스트의 기능이 거의 없다시피 하다.
- Reverse 함수
- 언리얼의 TArray에는 Reverse가 내장되어 있지 않다.
그렇기에 #include “Algo/Reverse.h”를 해준 뒤, Algo::Reverse 함수를 사용하여 해주었다.
- 언리얼의 TArray에는 Reverse가 내장되어 있지 않다.
Tick() 파트
// Called every frame
void APathfinding::Tick(float DeltaTime)
{
Super::Tick(DeltaTime);
// Seeker&Target 검사. 유효성 검사를 하지 않으면 크래시 나므로 주의.
if (!Seeker || !Target) return;
// 매 틱마다 Path 검사
FindPath(Seeker->GetActorLocation(), Target->GetActorLocation());
}
📜 Grid.h
USTRUCT(Atomic)
struct FNode {
GENERATED_USTRUCT_BODY()
public:
bool bWalkable;
FVector WorldPosition;
FNode* Parent;
// 그리드 내 배열 좌표
int GridX;
int GridY;
int GCost;
int HCost;
int FCost() { return GCost + HCost; }
FNode() {
bWalkable = true;
WorldPosition = FVector(0, 0, 0);
Parent = nullptr;
GridX = 0;
GridY = 0;
}
FNode(bool _walkable, FVector _worldPos, int _gridX, int _gridY) {
bWalkable = _walkable;
WorldPosition = _worldPos;
GridX = _gridX;
GridY = _gridY;
}
bool operator==(const FNode& Other) const {
return WorldPosition == Other.WorldPosition;
}
};
FORCEINLINE uint32 GetTypeHash(const FNode& Node) {
return GetTypeHash(Node.WorldPosition);
}
// 넣어주어야 에디터 상에서 해당 클래스를 다른 블루프린트 변수에 값으로 넣어줄수 있음
UCLASS(BlueprintType)
class SL_ASTAR_API AGrid : public AActor
{
public:
TArray<FNode*> Path; // 최종 경로
// ...
}
👉🏻 특징
- GetTypeHash()와 등위 연산자 오버로딩
- TSet또는 TMap에 커스텀 구조체(FNode)를 넣어주기 위해선,
등위 연산자(operator==)와 GetTypeHash()를 추가해주어야만 한다.
참고 : https://devjino.tistory.com/267
- TSet또는 TMap에 커스텀 구조체(FNode)를 넣어주기 위해선,
- GridX, GridY, G, H, F 변수 추가
- 새로 추가된 Pathfinding 클래스를 위해 선언하였다.
- UCLASS(BlueprintType)
- 에디터 상에서 해당 클래스를 다른 블루프린트에 넣어주기 위함
🏃🏻 실행
올바르게 경로가 나타나는 것을 확인할 수 있다.
🪶 깃허브
Sebastian Lague - Unity
Me - UE5
댓글남기기