TIL
06.30 TIL
2026. 6. 30. 17:57

일정표

시간 할 일 비고
08:30~10:00 코드카타, 디자인 패턴  
10:00~13:00 개인 작업  
14:00~15:00 오후 팀플(오류 수정, 추가 작업)  
15:00~18:00 기초반 수업 및 복습  
19:00~21:00 밍글데이 / 개인 공부  
21:00~22:00 운동  
22:00~23:00 개인 작업  

*오늘의 코드카타*

 

문제. 가장 가까운 같은 글

더보기

문자열 s가 주어졌을 때, s의 각 위치마다 자신보다 앞에 나왔으면서, 자신과 가장 가까운 곳에 있는 같은 글자가 어디 있는지 알고 싶습니다

b는 처음 나왔기 때문에 자신의 앞에 같은 글자가 없습니다. 이는 -1로 표현합니다.

a는 처음 나왔기 때문에 자신의 앞에 같은 글자가 없습니다. 이는 -1로 표현합니다.

n은 처음 나왔기 때문에 자신의 앞에 같은 글자가 없습니다. 이는 -1로 표현합니다.

a는 자신보다 두 칸 앞에 a가 있습니다. 이는 2로 표현합니다.

n도 자신보다 두 칸 앞에 n이 있습니다. 이는 2로 표현합니다.

a는 자신보다 두 칸, 네 칸 앞에 a가 있습니다. 이 중 가까운 것은 두 칸 앞이고, 이는 2로 표현합니다

따라서 최종 결과물은 [-1, -1, -1, 2, 2, 2]가 됩니다

문자열 s이 주어질 때, 위와 같이 정의된 연산을 수행하는 함수 solution을 완성해주세요

#include <string>
#include <vector>

using namespace std;

vector<int> solution(string s) {
    vector<int> answer;
    
    for (int i = 0; i < s.length(); ++i)
    {
        int distance = -1;
        
        for (int j = i - 1; j >= 0; --j)
        {
            if (s[i] == s[j])
            {
                distance = i - j;
                break;
            }
        }
        answer.push_back(distance);
    }
    
    return answer;
}

(1) 이중 반복문을 이용한 풀이. 간단하고 쉽지만 문제는 글자를 매번 일일이 검사해야 하므로 문자열의 길이가 N일 때 시간 복잡도가 O(N^2)이 된다.

 

#include <string>
#include <vector>
#include <unordered_map>

using namespace std;

vector<int> solution(string s) {
    vector<int> answer;
    answer.reserve(s.length());
    
    unordered_map<char, int> memo;
    
    for (int i = 0; i < s.length(); ++i)
    {
        char current_char = s[i];
        
        if (memo.find(current_char) != memo.end())
        {
            answer.push_back(i - memo[current_char]);
        }
        else 
        {
            answer.push_back(-1);    
        }
        
        memo[current_char] = i;
    }
    
    return answer;
}

(2) std::unordered_map을 활용하는 방법. 문자열을 뒤로 탐색할 필요가 없이 알파벳 한 글자를 발견하면 memo에 기록하며 한바퀴만 도는 방식이다. 한 바퀴만 순회하므로 O(N), memo unordered_map 을 생성하는 데에 O(N)의 추가 메모리가 필요하게 된다. 

실제 코딩테스트나 대용량 데이터를 다루는 환경에서는 unordered_map을 사용하는 것이 유리하다.


*Unreal C++*

1. 서비스 로케이터 패턴 (Service Locator)

 

엔진 내부 또는 서드파티 모듈(예를 들어, 사운드 엔진, 분석 SDK)의 의존성을 주입하고 중앙에서 스위칭하는 시스템을 구축할 때 핵심이 되는 패턴.

 

1. C++ 개념 및 코드

 

서비스 로케이터 패턴의 핵심 목적은 "구체적인 서비스 구현 클래스가 무엇인지 클라이언트에게 숨긴 채, 전역적으로 접근 가능한 '중앙 창구(Locator)'를 통해 필요한 서비스 인스턴스를 제공하는 것"이다.

 

얼핏 보면 싱글톤 패턴과 비슷해보이지만, 싱글톤은 클래스 자체가 전역 인스턴스를 직접 들고 있지만, 서비스 로케이터는 인터페이스만 알고 있으며, 런타임에 언제든지 구체적인 구현체를 갈아끼울 수 있다.

#include <iostream>
#include <memory>

// 1. 서비스 인터페이스 정의
class IAnalyticsService {
public:
    virtual ~IAnalyticsService() = default;
    virtual void LogEvent(const std::string& EventName) = 0;
};

// 2. 구체적인 서비스 구현체 A (예: 내부 파일 로그용)
class LocalAnalytics : public IAnalyticsService {
public:
    void LogEvent(const std::string& EventName) override {
        std::cout << "[Local] 로그 기록됨: " << EventName << std::endl;
    }
};

// 3. 구체적인 서비스 구현체 B (예: 상업용 외부 SDK - Firebase 등)
class FirebaseAnalytics : public IAnalyticsService {
public:
    void LogEvent(const std::string& EventName) override {
        std::cout << "[Firebase] 서버로 이벤트 전송: " << EventName << std::endl;
    }
};

// 4. 서비스 로케이터 (중앙 창구)
class ServiceLocator {
private:
    static std::shared_ptr<IAnalyticsService> AnalyticsInstance;

public:
    static void RegisterAnalyticsService(std::shared_ptr<IAnalyticsService> Service) {
        AnalyticsInstance = Service;
    }

    static std::shared_ptr<IAnalyticsService> GetAnalytics() {
        return AnalyticsInstance;
    }
};

// static 변수 초기화
std::shared_ptr<IAnalyticsService> ServiceLocator::AnalyticsInstance = nullptr;

클라이언트는 ServiceLocator::GetAnalytics()->LogEvent("GameStart")만 호출하면 된다. 현재 백엔드가 로컬 파일 시스템인지, 외부 SDK인지 전혀 알 필요가 없어 결합도를 매우 낮출 수 있다.


2. 언리얼 C++ 적용

 

위의 C++ 코드를 언리얼 엔진에 그대로 가져와 static 포인터로 관리하면 다음과 같은 문제가 발생하게 된다.

 

1) 언리얼 리플렉션 및 GC 누락 : 외부 서드파티 SDK뿐만 아니라 언리얼의 UObject나 AActor 기반 서비스를 로케이터에 등록하고 싶을 때, 표준 C++의 std::shared_ptr이나 원시 static 포인터는 언리얼 GC의 추적을 받지 못한다. 따라서 레벨이 전환될 때 메모리가 통째로 날아가 댕글링 포인터 크래시가 터진다.

 

2) PIE 메모리 오염 : 에디터에서 플레이를 종료해도 static 메모리는 프로세스가 켜져있는 한 유지된다. 즉, 다음 플레이에도 메모리가 남아있어서 데이터가 꼬이거나 에디터 크래시를 유발한다.

 

그래서 언리얼 엔진에서는 static 클래스를 만드는 대신, 엔진의 수명 주기를 다루는 UGameInstanceSubsystem을 서비스 로케이터의 본체로 활용한다. 그리고 여기에 더해 언리얼의 리플렉션을 타는 인터페이스인 UInterface를 활용해 구현체를 안전하게 관리한다.


3. 언리얼 C++ 실무 코드

 

개발 빌드에서는 자체 로그(Local)를 남기고, 라이브 상용 빌드에서는 외부 분석 SDK(Firebase 등)로 로그를 전송하는 상황을 가정해보자.

 

(1) 언리얼 인터페이스 정의(AnalyticsInterface.h)

//#pragma once

#include "CoreMinimal.h"
#include "UObject/Interface.h"
#include "AnalyticsInterface.generated.h"

UINTERFACE(MinimalAPI)
class UAnalyticsInterface : public UInterface
{
	GENERATED_BODY()
};

class UNREALDESIGNPATTERN_API IAnalyticsInterface
{
	GENERATED_BODY()

public:
	// 모든 분석 서비스가 지켜야 할 규약
	virtual void LogEvent(const FString& EventName) = 0;
};

 

(2) 서비스 로케이터 서브시스템 구현(AnalyticsLocatorSubsystem.h)

#pragma once

#include "CoreMinimal.h"
#include "Subsystems/GameInstanceSubsystem.h"
#include "AnalyticsInterface.h"
#include "AnalyticsLocatorSubsystem.generated.h"

/**
 * 전역에서 안전하게 접근 가능한 언리얼 친화적 서비스 로케이터
 */
UCLASS()
class UNREALDESIGNPATTERN_API UAnalyticsLocatorSubsystem : public UGameInstanceSubsystem
{
	GENERATED_BODY()

public:
	// 서비스 등록 (의존성 주입 창구)
	void RegisterAnalyticsService(TScriptInterface<IAnalyticsInterface> Service)
	{
		CurrentService = Service;
	}

	// 서비스 인터페이스 반환 (Null이면 더미 서비스를 반환하도록 안정성 확보 가능)
	IAnalyticsInterface* GetAnalytics() const
	{
		if (CurrentService.GetInterface() != nullptr)
		{
			return CurrentService.GetInterface();
		}
		return nullptr;
	}

private:
	// TScriptInterface를 통해 UObject 기반 인터페이스 객체를 GC로부터 안전하게 보호
	UPROPERTY()
	TScriptInterface<IAnalyticsInterface> CurrentService;
};

 

(3) 게임 초기화 시점에 서비스 등록 (GameMode.cpp 등 진입점에서 사용)

void AMyGameMode::InitGame(const FString& MapName, const FString& Options, FString& ErrorMessage)
{
	Super::InitGame(MapName, Options, ErrorMessage);

	UAnalyticsLocatorSubsystem* Locator = GetGameInstance()->GetSubsystem<UAnalyticsLocatorSubsystem>();
	if (Locator)
	{
#if WITH_EDITOR
		// 에디터/개발 빌드에서는 로컬 로그 서비스 스폰 및 등록
		UMyLocalAnalytics* LocalService = NewObject<UMyLocalAnalytics>(this);
		Locator->RegisterAnalyticsService(LocalService);
#else
		// 라이브 상용 빌드에서는 외부 PlayFab SDK 서비스 스폰 및 등록
		UMyPlayFabAnalytics* LiveService = NewObject<UMyPlayFabAnalytics>(this);
		Locator->RegisterAnalyticsService(LiveService);
#endif
	}
}

 

(4) 실제 클라이언트에서의 호출 방식

void AMyCharacter::Die()
{
	// 캐릭터는 현재 백엔드가 무엇인지 몰라도 전역에서 안전하게 호출 가능
	if (UGameInstance* GameInstance = GetGameInstance())
	{
		if (auto* Locator = GameInstance->GetSubsystem<UAnalyticsLocatorSubsystem>())
		{
			if (IAnalyticsInterface* Analytics = Locator->GetAnalytics())
			{
				Analytics->LogEvent(TEXT("Player_Death"));
			}
		}
	}
}

 

핵심 : 언리얼에서 서비스 로케이터는 UGameInstanceSubsystem의 라이프사이클과 TScriptInterfacedml GC 보호 능력을 결합하여 완성된다.


2. 더블 버퍼링 (Double Buffering)

그래픽스 하드웨어뿐만 아니라, 프레임 간 데이터 오염을 막고 병렬 계산을 안전하게 처리하기 위한 모던 게임 아키텍처의 핵심 기술.

 

1. C++ 개념 및 코드

만약 버퍼가 하나라면, 그래픽스에서 '티어링(Tearing, 화면이 찢어지는 현상)'이라고 부르는 현상이 발생하고, 일반 로직에서는 '데이터 오염'이라고 하는 현상이 발생하게 된다. 현재 보여지는 상태와 다음 준비 중인 상태를 분리해 두지 않으면, 데이터가 변경되는 불안정한 중간 과정을 클라이언트에게 노출하게 된다.

 

따라서 더블 버퍼링은 데이터를 변경하는 도중에 다른 스레드가 데이터를 읽어가더라도, 읽는 스레드는 항상 완벽하게 완성된 CurrentBuffer만 바라보게 해서 데이터 경쟁(Data Race)이나 미완성된 렌더링 글리치를 차단하는 방식이다.

#include <iostream>
#include <vector>

// 간단한 2D 픽셀 화면을 시뮬레이션하는 클래스
class Framebuffer {
private:
    static const int WIDTH = 10;
    static const int HEIGHT = 2;
    
    // 두 개의 버퍼를 준비. (0번과 1번)
    char buffers[2][WIDTH * HEIGHT];
    int currentBufferIndex;

public:
    Framebuffer() : currentBufferIndex(0) {
        // 버퍼 초기화
        for (int i = 0; i < WIDTH * HEIGHT; ++i) {
            buffers[0][i] = '.';
            buffers[1][i] = '.';
        }
    }

    // 현재 화면에 출력 중인 버퍼(Front)는 읽기만 가능
    void Render() const {
        std::cout << "--- 현재 화면 (Front Buffer: " << currentBufferIndex << ") ---\n";
        for (int y = 0; y < HEIGHT; ++y) {
            for (int x = 0; x < WIDTH; ++x) {
                std::cout << buffers[currentBufferIndex][y * WIDTH + x];
            }
            std::cout << "\n";
        }
    }

    // 다음 출력할 버퍼(Back)에 비동기적이거나 무거운 쓰기 작업 수행
    void DrawPixel(int x, int y, char pixel) {
        int backBufferIndex = 1 - currentBufferIndex; // 현재 버퍼의 반대편
        buffers[backBufferIndex][y * WIDTH + x] = pixel;
    }

    // 그리기가 끝나면 두 버퍼를 교체(Swap)
    void Swap() {
        currentBufferIndex = 1 - currentBufferIndex;
    }
};

int main() {
    Framebuffer screen;
    screen.Render(); // 초기 화면 출력

    // 백버퍼에 다음 프레임 그리기 (작업 중에는 화면에 보이지 않음)
    screen.DrawPixel(2, 0, 'P');
    screen.DrawPixel(5, 1, 'X');
    
    // 작업이 완전히 끝난 후 스왑
    screen.Swap();
    screen.Render(); // 업데이트된 화면 출력

    return 0;
}

2. 언리얼 C++ 적용

 

언리얼 엔진은 이 더블 버퍼링 철학을 엔진 구조 레벨에서 사용하고 있다. 대표적인 예가 바로 게임 스레드(Game Thread)와 렌더 스레드(Render Thread)의 분리이다.

 

1) 엔진 레벨의 더블 버퍼링 : 게임 스레드가 N번째 프레임의 위치, 애니메이션, 물리 연산을 수행하는 동안, 렌더 스레드는 지난 프레임의 스냅샷 데이터를 가지고 그래픽카드로 화면을 그린다. 이 두 스레드가 서로의 데이터를 침범하지 않도록 병렬 버퍼링 구조가 내장되어 있다.

 

2) 게임 플레이에서의 필요성 : 엔진 자체의 렌더링 외에도, 프로그래머가 직접 "그리드 기반의 대규모 시뮬레이션"을 구현할 때 이 패턴이 필요하게 된다. 주변 타일의 정보를 읽어서 내 타일의 다음 상태를 결정해야 할 때, 단일 배열을 사용하면 먼저 연산된 타일의 값이 다음 타일 연산에 영향을 주어 시뮬레이션이 한쪽으로 치우치고 오염되는 문제가 발생한다.

 

 


3. 언리얼 C++ 실무 코드

 

게임 월드 전체에 영향을 주는 실시간 화재 확산 시뮬레이션 시스템을 '그리드 시뮬레이션을 위한 더블 버퍼 컴포넌트' 개념을 사용해서 구현해보자. 단, 연산 도중에 데이터가 밀려서 불길이 기형적으로 빠르게 번지지 않도록 하자.

 

(1) 더블 버퍼링 기반 시뮬레이션 컴포넌트 (GridSimulationComponent.h)

//#pragma once

#include "CoreMinimal.h"
#include "Components/ActorComponent.h"
#include "GridSimulationComponent.generated.h"

UCLASS(ClassGroup=(Custom), meta=(BlueprintSpawnableComponent))
class UNREALDESIGNPATTERN_API UGridSimulationComponent : public UActorComponent
{
	GENERATED_BODY()

public:	
	UGridSimulationComponent();

protected:
	virtual void BeginPlay() override;

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

	// 외부(렌더러 등)에서 현재 완성된 데이터를 안전하게 읽어갈 수 있는 Getter
	const TArray<float>& GetCurrentSimulationData() const 
	{ 
		return SimulationBuffers[CurrentBufferIndex]; 
	}

private:
	static const int32 GRID_SIZE = 128; // 128x128 그리드

	// 두 개의 버퍼를 관리하는 배열 (더블 버퍼 본체)
	TArray<float> SimulationBuffers[2];
	int32 CurrentBufferIndex;

	// 시뮬레이션 내부 로직 업데이트 함수
	void UpdateSimulation();
};

 

(2) 버퍼 분리 및 스왑 구현 (GridSimulationComponent.cpp)

#include "GridSimulationComponent.h"

UGridSimulationComponent::UGridSimulationComponent()
{
	PrimaryComponentTick.bCanEverTick = true;
	CurrentBufferIndex = 0;
}

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

	// 두 버퍼의 메모리를 미리 확보하고 0(불 안 남)으로 초기화
	SimulationBuffers[0].Init(0.0f, GRID_SIZE * GRID_SIZE);
	SimulationBuffers[1].Init(0.0f, GRID_SIZE * GRID_SIZE);

	// 맵 중앙에 임의로 불씨(초기 데이터) 주입
	int32 Center = GRID_SIZE / 2;
	SimulationBuffers[0][Center * GRID_SIZE + Center] = 1.0f;
}

void UGridSimulationComponent::TickComponent(float DeltaTime, ELevelTick TickType, FActorComponentTickFunction* ThisTickFunction)
{
	Super::TickComponent(DeltaTime, TickType, ThisTickFunction);

	// 매 프레임 시뮬레이션을 돌려 다음 상태를 백버퍼에 기록 후 스왑
	UpdateSimulation();
}

void UGridSimulationComponent::UpdateSimulation()
{
	int32 ReadIndex = CurrentBufferIndex;
	int32 WriteIndex = 1 - CurrentBufferIndex; // 커튼 뒤의 백버퍼

	const TArray<float>& ReadBuffer = SimulationBuffers[ReadIndex];
	TArray<float>& WriteBuffer = SimulationBuffers[WriteIndex];

	// 그리드 순회 연산
	for (int32 Y = 1; Y < GRID_SIZE - 1; ++Y)
	{
		for (int32 X = 1; X < GRID_SIZE - 1; ++X)
		{
			int32 Idx = Y * GRID_SIZE + X;

			// 주변 타일의 값을 읽을 때는 오직 '완성된 상태(ReadBuffer)'만 참조.
			float NeighborsSum = 
				ReadBuffer[(Y - 1) * GRID_SIZE + X] + // 상
				ReadBuffer[(Y + 1) * GRID_SIZE + X] + // 하
				ReadBuffer[Y * GRID_SIZE + (X - 1)] + // 좌
				ReadBuffer[Y * GRID_SIZE + (X + 1)];  // 우

			// 열 확산 공식 적용 후 결과를 '백버퍼(WriteBuffer)'에만 안전하게 기록
			WriteBuffer[Idx] = ReadBuffer[Idx] + (NeighborsSum * 0.1f);
			WriteBuffer[Idx] = FMath::Clamp(WriteBuffer[Idx], 0.0f, 1.0f);
		}
	}

	// 모든 그리드의 연산이 완벽히 끝난 이 시점에 포인터(인덱스)를 스왑
	// 다음 프레임부터 클라이언트는 방금 완성된 WriteBuffer를 보게 된다.
	CurrentBufferIndex = WriteIndex;
}

 

더블 버퍼링은 수많은 행렬 계산이나 물리, 환경 시뮬레이션을 다룰 때 "읽기 전용 데이터"와 "쓰기 전용 데이터"를 격리하는 가장 확실하고 단순한 최적화 장치이다. 이 방식을 쓰면 멀티스레드 환경에서도 Lock을 걸지 않고 안전하게 데이터를 주고받을 수 있게 된다.

 


4. 추가 내용

 

더블 버퍼링을 좀 더 자세히 이해해보자.

 

1단계 : 최초 상태 (프레임 시작)

Front 포인터는 버퍼 A를 가리키고 있다.

Back 포인터는 버퍼 B를 가리키고 있다.

--> 모니터는 Front 포인터를 따라 버퍼 A에 그려진 완성된 그림을 본다.

 

2단계 : 연산 및 쓰기 상태 (프레임 중간)

읽기 작업(Reader) : 화면을 그리거나 주변 타일의 정보를 읽는 로직은 오직 Front가 가리키는 버퍼 A만 안전하게 읽어간다.

쓰기 작업(Writer) : 시뮬레이션 로직은 Back 포인터, 버퍼 B에만 미친 외적 연산 결과를 미리 받아둔다.

--> 버퍼 B는 실시간으로 데이터가 파괴되고 새로 그려지는 불안정한 상태이다. 하지만 여전히 모니터에는 버퍼 A만 보이고 있으므로 티어링이나 데이터 오염을 느낄 수 없다.

 

3단계 : 스왑 (Swap)

버퍼 B에 다음 프레임에 보여줄 연산이 100% 완료가 되면 화면을 전환한다. 이때, 버퍼 B의 데이터를 버퍼 A로 복사하는 것이 아니라 주소값만 바꾼다(스왑).

Temp = Front;
Front = Back;  // 이제 Front가 버퍼 B를 가리킴
Back = Temp;  // 이제 Back이 버퍼 A를 가리킴

--> 다음 프레임이 시작되자마자, 모니터는 Front를 따라 버퍼 B를 보게 된다. 그리고 Writer는 비워진 버퍼 A를 다시 Back으로 놓고 다음 연산을 시작한다.

 

...

 

더블 버퍼링은 게임이 켜져 있는 한 무한히 반복되고, 매 프레임 쉬지 않고 계속 실행된다.  더블 버퍼링은 아까 살펴봤듯이 두 가지 레이어로 나뉘어 존재하므로, 구현 목적에 따라 배치하는 위치가 달라진다.

1) 화면을 그리는 렌더링 더블 버퍼링 (엔진이 전담함) : 언리얼 엔진의 FRenderThread와 RHI(Rendering Hardware Interface) 시스템 레이어에 내장되어 자동으로 돌아가고 있다.

 

2) 게임플레이 데이터를 다루는 더블 버퍼링 (콘텐츠 프로그래머가 전담함) : 데이터 오염을 막기 위한 로직은 프로그래머가 직접 구현해야 한다. 이때 주로 UActorComponent(특정 액터에 종속되어 해당 범위 내의 시뮬레이션 데이터만 안전하게 버퍼링할 때 사용), UGameInstanceSubsystem(게임 월드 전체에 영향을 주는 글로벌 데이터를 멀티스레드로 안전하게 연산하고 관리해야 할 때 사용)에 더블 버퍼링을 배치하여 사용하게 된다.

 


 

3. 공간 분할 (Spatial Partitioning)

대규모 멀티플레이 게임이나 오픈월드에서 "수천 수만 개의 객체 중 내 주변에 있는 것들만 빠른 속도로 찾아내는" 탐색 기술.

 

1. C++ 개념 및 코드

 

공간 분할 패턴의 핵심 목적은 2D 또는 3D 공간을 수학적인 규칙에 따라 여러 구역으로 쪼개어 관리함으로써, 객체 간의 거리 측정 및 충돌 검사 비용을 극단적으로 줄이는 것이다. 모든 액션/RTS 게임에서 가장 빈번하게 발생하는 연산은 "내 주변에 적이 있는가?"를 검사하는 것인데, 만약 월드에 유닛이 1,000마리가 있다면 공간 분할이 없을 때 모든 유닛이 서로를 감시하기 위해 루프를 돌면 O(N^2)이므로 백만 번의 거리 계산을 매 프레임마다 하게 된다.

 

따라서 공간 분할은 공간을 Grid로 나누거나 쿼드트리, 옥트리 구조를 사용해서 이 비용을 O(1) 또는 O(log N) 수준으로 떨어뜨린다. 먼저 2D 격자 방식(Spatial Hash Grid)의 C++ 코드를 살펴보자.

//#include <iostream>
#include <vector>
#include <list>

struct Vector2D { float X; float Y; };

// 공간에 배치될 가벼운 유닛 객체
class Unit {
public:
    std::string Name;
    Vector2D Position;
    Unit(std::string InName, float InX, float InY) : Name(InName), Position({InX, InY}) {}
};

// 2D 격자 공간 분할 클래스
class SpatialGrid2D {
private:
    static const int CELL_SIZE = 100; // 가로세로 100 크기의 격자
    static const int GRID_SIZE = 10;  // 10x10 격자 맵
    
    // 각 격자(칸)마다 유닛들을 담아둘 바구니들의 2차원 배열
    std::vector<Unit*> Buckets[GRID_SIZE][GRID_SIZE];

public:
    // 유닛을 위치에 맞는 격자 바구니에 등록
    void RegisterUnit(Unit* InUnit) {
        int CellX = static_cast<int>(InUnit->Position.X / CELL_SIZE);
        int CellY = static_cast<int>(InUnit->Position.Y / CELL_SIZE);
        
        if (CellX >= 0 && CellX < GRID_SIZE && CellY >= 0 && CellY < GRID_SIZE) {
            Buckets[CellX][CellY].push_back(InUnit);
        }
    }

    // 특정 위치 주변(같은 격자)의 유닛들만 광속 탐색
    void FindNearbyUnits(Vector2D SearchPos, std::vector<Unit*>& OutUnits) {
        int CellX = static_cast<int>(SearchPos.X / CELL_SIZE);
        int CellY = static_cast<int>(SearchPos.Y / CELL_SIZE);

        if (CellX >= 0 && CellX < GRID_SIZE && CellY >= 0 && CellY < GRID_SIZE) {
            // 전수 조사를 하지 않고, 오직 '같은 칸'에 있는 유닛 목록만 바로 반환
            OutUnits = Buckets[CellX][CellY];
        }
    }
};

2. 언리얼 C++ 적용

 

언리얼 엔진은 이미 이 공간 분할 아키텍처를 활용하고 있다.

 

1) 엔진 내부의 공간 분할 기술 : 거대한 오픈월드의 레벨을 바둑판 형태로 쪼개어 플레이어 주변만 로딩하는 월드 파티션(World Partition), 수만 개의 나무 인스턴스를 효율적으로 렌더링하기 위해 공간을 쪼개는 HISM의 옥트리 구조, 콜리전 충돌 검사를 수행하는 Chaos 물리 엔진의 Broadphase Grid가 내장되어 있는 공간 분할 시스템이다.

 

2) 일반적인 C++ 코드를 직접 구현해서 언리얼에서 사용하면 GC 추적과 매 프레임 움직이는 액터의 위치 동기화 처리에 병목현상과 크래시를 겪게 되므로, 언리얼이 내장한 GC 컨테이너 규칙을 지키면서 물리 엔진에 큰 부담을 주지 않는 게임 플레이 전담 커스텀 가상 격자 서브시스템을 구축하여 연산 오버헤드를 우회하는 것이 정석이다.


3. 언리얼 C++ 실무 코드

 

수백 마리의 몬스터들이 매 프레임 가장 가까운 적을 찾아서 조준해야 한다. 단, 무거운 물리 콜리전(Overlap Query)을 수백 번 쏘아 CPU 병목을 유발하지 말고, 순수 수학적으로 최적화하는 과정을 살펴보자. 앞서 오브젝트 풀 패턴에서 배웠던 "이중 컨테이너 우회를 위한 래퍼 구조체(USTRUCT) 테크닉을 여기에서도 활용해서 GC 안정성을 확보한다.

 

(1) 격자 바구니 구조체 및 컴포넌트 정의(SpatialGridComponent.h)

//#pragma once

#include "CoreMinimal.h"
#include "Components/ActorComponent.h"
#include "SpatialGridComponent.generated.h"

// 언리얼 GC가 인식할 수 있도록 TArray를 감싸는 래퍼 구조체 선언
USTRUCT()
struct FGridBucket
{
	GENERATED_BODY()

	UPROPERTY()
	TArray<AActor*> RegisteredActors;
};

UCLASS(ClassGroup=(Custom), meta=(BlueprintSpawnableComponent))
class UNREALDESIGNPATTERN_API USpatialGridComponent : public UActorComponent
{
	GENERATED_BODY()

public:	
	USpatialGridComponent();

	// 매 프레임 초기화 후 유닛들을 재배치하기 위한 함수
	void ClearGrid();
	void RegisterActor(AActor* TargetActor);
	
	// 특정 포지션 주변의 액터들만 빠르게 긁어오는 함수
	void GetNearbyActors(const FVector& SearchPosition, TArray<AActor*>& OutActors);

private:
	static const int32 CELL_SIZE = 500; // 격자 한 칸의 크기 (500cm = 5m)
	static const int32 GRID_COUNT = 20; // 20x20 격자 맵

	// UPROPERTY 매크로를 통해 안전하게 가비지 컬렉터의 보호를 받는 2차원 대용 격자 맵
	UPROPERTY()
	TMap<int32, FGridBucket> SpatialGridMap;

	// 2D 좌표를 단일 해시 키(정수)로 변환하는 헬퍼 함수
	int32 ComputeHashKey(const FVector& Position) const;
};

 

(2) 가상 공간 분할 및 탐색 구현 (SpatialGridComponent.cpp)

#include "SpatialGridComponent.h"
#include "GameFramework/Actor.h"

USpatialGridComponent::USpatialGridComponent()
{
	PrimaryComponentTick.bCanEverTick = false; // 격자는 매니저가 제어하므로 틱은 끈다.
}

int32 USpatialGridComponent::ComputeHashKey(const FVector& Position) const
{
	// 3D 좌표 중 X와 Y만 사용하여 2D 격자 인덱스를 구하고, 이를 고유한 정수 키로 결합
	int32 CellX = FMath::FloorToInt(Position.X / CELL_SIZE);
	int32 CellY = FMath::FloorToInt(Position.Y / CELL_SIZE);
	
	// 결합 예시 (Bit Shifting을 통한 고유 키 생성)
	return (CellX << 16) ^ CellY;
}

void USpatialGridComponent::ClearGrid()
{
	SpatialGridMap.Empty();
}

void USpatialGridComponent::RegisterActor(AActor* TargetActor)
{
	if (!IsValid(TargetActor)) return;

	int32 HashKey = ComputeHashKey(TargetActor->GetActorLocation());
	
	// 맵에 해당 격자 칸이 없으면 생성하고 액터 등록
	SpatialGridMap.FindOrAdd(HashKey).RegisteredActors.Add(TargetActor);
}

void USpatialGridComponent::GetNearbyActors(const FVector& SearchPosition, TArray<AActor*>& OutActors)
{
	int32 CenterKey = ComputeHashKey(SearchPosition);

	// 내 위치가 속한 격자 칸뿐만 아니라 주변 8방향 격자(총 9칸)를 순회하며 안전하게 수집 가능
	int32 CenterX = FMath::FloorToInt(SearchPosition.X / CELL_SIZE);
	int32 CenterY = FMath::FloorToInt(SearchPosition.Y / CELL_SIZE);

	for (int32 OffsetX = -1; OffsetX <= 1; ++OffsetX)
	{
		for (int32 OffsetY = -1; OffsetY <= 1; ++OffsetY)
		{
			int32 TargetKey = ((CenterX + OffsetX) << 16) ^ (CenterY + OffsetY);
			
			if (FGridBucket* Bucket = SpatialGridMap.Find(TargetKey))
			{
				OutActors.Append(Bucket->RegisteredActors);
			}
		}
	}
}

 

(3) 글로벌 매니저(AI 시스템)에서의 활용

void AMyAIManager::UpdateEnemyTargets()
{
	// 1. 매 프레임 공간 분할 컴포넌트를 깔끔하게 비운다.
	GridComponent->ClearGrid();

	// 2. 월드의 모든 미니언들을 현재 위치 기반으로 격자에 재등록한다.
	for (AActor* Minion : AllMinions)
	{
		GridComponent->RegisterActor(Minion);
	}

	// 3. 탐색할 때는 수천 마리를 루프 돌지 않고 주변 격자만 탐색한다.
	for (AMyCharacter* Attacker : ActiveAttackers)
	{
		TArray<AActor*> NearbyEnemies;
		GridComponent->GetNearbyActors(Attacker->GetActorLocation(), NearbyEnemies);

		// 전체 1만 개가 아닌, 내 주변 9개 칸에 존재하는 10~20개의 액터하고만 거리 비교 연산 수행.
		AActor* ClosestEnemy = GetClosestEnemy(Attacker, NearbyEnemies);
		Attacker->SetTarget(ClosestEnemy);
	}
}

 

이처럼, 공간 분할 패턴은 물리 엔진의 레이캐스트(Raycast)나 오버랩(Overlap) 비용을 쓰지 않고도 C++배열과 해시 연산만으로 수많은 유닛 위치 관계를 필터링할 수 있는 아키텍처이다. 이 방식을 알면 언리얼의 HISM, World Partition의 매커니즘을 이해하고 통제할 수 있게 된다.

 

 

4. 추가 내용

 

수백 개의 객체가 매 프레임 위치를 바꾸는(Dynamic Objects) 상황에는, 객체가 움직일 때마다 기존 격자 바구니에서 빼내어 새로운 격자 바구니로 옮겨주는 "재해싱(Re-hashing)연산"이 필요하다. C++에서는 메모리 할당 오버헤드를 줄이기 위해 배열을 지우고 다시 쓰기(Clear & Rebuild) 전략을 매 프레임 취하거나, 유닛 내부에 자신이 속한 격자 인덱스를 기억하게 하여 포인터를 교체한다.

 

#include <iostream>
#include <vector>
#include <unordered_map>

// 공간 분할 격자 내에서 관리될 가벼운 유닛 데이터
struct GridAgent {
    int32_t UniqueID;
    float X, Y;
    int32_t CurrentCellKey = -1; // 현재 자신이 속한 격자의 해시 키 기억
};

class DynamicSpatialHash {
private:
    int32_t CellSize;
    // 고유 격자 키(Key) 하나에 여러 유닛들의 ID(Value)를 매핑
    std::unordered_map<int32_t, std::vector<GridAgent*>> GridMap;

public:
    DynamicSpatialHash(int32_t InCellSize) : CellSize(InCellSize) {}

    int32_t ComputeKey(float X, float Y) {
        int32_t CellX = static_cast<int32_t>(X / CellSize);
        int32_t CellY = static_cast<int32_t>(Y / CellSize);
        return (CellX << 16) ^ CellY;
    }

    // 유닛이 움직일 때마다 호출되는 격자 갱신 함수
    void UpdateAgent(GridAgent* Agent, float NewX, float NewY) {
        Agent->X = NewX;
        Agent->Y = NewY;
        int32_t NewKey = ComputeKey(NewX, NewY);

        // 이전 격자와 달라진 경우에만 바구니 이동 (연산 최소화)
        if (Agent->CurrentCellKey != NewKey) {
            if (Agent->CurrentCellKey != -1) {
                // 기존 바구니에서 제거
                auto& OldBucket = GridMap[Agent->CurrentCellKey];
                for (auto it = OldBucket.begin(); it != OldBucket.end(); ++it) {
                    if ((*it)->UniqueID == Agent->UniqueID) {
                        OldBucket.erase(it);
                        break;
                    }
                }
            }
            // 새 바구니에 주입
            GridMap[NewKey].push_back(Agent);
            Agent->CurrentCellKey = NewKey;
        }
    }
};

 

언리얼 에서는 AActor 충돌이 3D 공간의 무거운 물리 연산하에서 이루어지기 때문에, 평면에서 단순히 가까운 대상을 필터링하는 용도로 쓰기에는 캡슐 컴포넌트의 물리 동기화 비용이 너무나 크다. 또한, 수백 개의 몬스터와 NPC 액터 포인터를 들고 있어야 하므로, 언리얼 GC가 추적할 수 있도록 USTRUCT로 TArray<AActor*>를 감싸고 이를 TMap으로 관리하는 구조가 필요하다.

 

(1) 격자 등록용 컴포넌트 및 에이전트 인터페이스 (SpatialAgentComponent.h)

#pragma once

#include "CoreMinimal.h"
#include "Components/ActorComponent.h"
#include "SpatialAgentComponent.generated.h"

// 에이전트의 성격을 분류하는 열거형
UENUM(BlueprintType)
enum class EAgentFaction : uint8
{
	PlayerMonster,
	EnemyMonster,
	TownNPC,
	Workstation
};

UCLASS(ClassGroup=(Custom), meta=(BlueprintSpawnableComponent))
class UNREALDESIGNPATTERN_API USpatialAgentComponent : public UActorComponent
{
	GENERATED_BODY()

public:	
	USpatialAgentComponent();

	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "SpatialAI")
	EAgentFaction Faction;

	// 현재 속한 격자 해시 키를 기억하여 불필요한 갱신 방지
	int32 CachedGridKey = -1;

protected:
	virtual void BeginPlay() override;
	virtual void EndPlay(const EEndPlayReason::Type EndPlayReason) override;
};

 

(2) 서브시스템 헤더 구성 (SimulationGridSubsystem.h)

#include "CoreMinimal.h"
#include "Subsystems/GameInstanceSubsystem.h"
#include "SpatialAgentComponent.h"
#include "SimulationGridSubsystem.generated.h"

USTRUCT()
struct FGridAgentBucket
{
	GENERATED_BODY()

	// 가비지 컬렉션의 안전한 관리를 받는 액터 포인터 배열
	UPROPERTY()
	TArray<AActor*> MemberActors;
};

/**
 * 2D 가상 공간 분할 시스템
 */
UCLASS()
class UNREALDESIGNPATTERN_API USimulationGridSubsystem : public UGameInstanceSubsystem
{
	GENERATED_BODY()

public:
	// 에이전트 등록 및 위치 동기화
	void UpdateAgentPosition(AActor* AgentActor, USpatialAgentComponent* AgentComponent);
	
	// 에이전트가 삭제되거나 죽었을 때 격자 명부에서 안전하게 청소
	void UnregisterAgent(AActor* AgentActor, int32 LastKey);

	// 오토배틀러용: 특정 반경 내의 특정 진영(Faction) 적들만 타겟팅 필터링
	void GetNearbyActorsByFaction(const FVector& SearchPos, float Radius, EAgentFaction TargetFaction, TArray<AActor*>& OutActors);

private:
	// 격자 한 칸의 가로세로 크기 (400cm로 설정)
	static const int32 CELL_SIZE = 400; 

	UPROPERTY()
	TMap<int32, FGridBucket> SpatialGridMap;

	int32 ComputeHashKey(const FVector& Position) const;
};

 

(3) 서브시스템 소스 파일 구현 (SimulationGridSubsystem.cpp)

#include "SimulationGridSubsystem.h"
#include "GameFramework/Actor.h"

int32 USimulationGridSubsystem::ComputeHashKey(const FVector& Position) const
{
	int32 CellX = FMath::FloorToInt(Position.X / CELL_SIZE);
	int32 CellY = FMath::FloorToInt(Position.Y / CELL_SIZE);
	return (CellX << 16) ^ CellY;
}

void USimulationGridSubsystem::UpdateAgentPosition(AActor* AgentActor, USpatialAgentComponent* AgentComponent)
{
	if (!IsValid(AgentActor) || !AgentComponent) return;

	int32 NewKey = ComputeHashKey(AgentActor->GetActorLocation());

	// 이전에 속했던 격자와 다른 칸으로 넘어갔을 때만 동기화 연산 수행
	if (AgentComponent->CachedGridKey != NewKey)
	{
		UnregisterAgent(AgentActor, AgentComponent->CachedGridKey);

		// 새 바구니에 주입
		SpatialGridMap.FindOrAdd(NewKey).MemberActors.Add(AgentActor);
		AgentComponent->CachedGridKey = NewKey;
	}
}

void USimulationGridSubsystem::UnregisterAgent(AActor* AgentActor, int32 LastKey)
{
	if (LastKey != -1 && SpatialGridMap.Contains(LastKey))
	{
		SpatialGridMap[LastKey].MemberActors.RemoveSingleSwap(AgentActor, false);
		
		// 바구니가 텅 비었다면 메모리 확보를 위해 제거
		if (SpatialGridMap[LastKey].MemberActors.Num() == 0)
		{
			SpatialGridMap.Remove(LastKey);
		}
	}
}

void USimulationGridSubsystem::GetNearbyActorsByFaction(const FVector& SearchPos, float Radius, EAgentFaction TargetFaction, TArray<AActor*>& OutActors)
{
	int32 CenterX = FMath::FloorToInt(SearchPos.X / CELL_SIZE);
	int32 CenterY = FMath::FloorToInt(SearchPos.Y / CELL_SIZE);

	// 탐색 반경이 격자 몇 칸에 걸쳐있는지 계산
	int32 CellRange = FMath::CeilToInt(Radius / CELL_SIZE);
	float RadiusSq = Radius * Radius;

	// 내 주변을 에워싼 격자 칸들만 콤팩트하게 순회
	for (int32 OffsetX = -CellRange; OffsetX <= CellRange; ++OffsetX)
	{
		for (int32 OffsetY = -CellRange; OffsetY <= CellRange; ++OffsetY)
		{
			int32 TargetKey = ((CenterX + OffsetX) << 16) ^ (CenterY + OffsetY);

			if (FGridBucket* Bucket = SpatialGridMap.Find(TargetKey))
			{
				for (AActor* Actor : Bucket->MemberActors)
				{
					if (!IsValid(Actor)) continue;

					// 1차 필터링: 컴포넌트를 긁어와 원하는 진영(Faction)인지 검사
					if (auto* AgentComp = Actor->FindComponentByClass<USpatialAgentComponent>())
					{
						if (AgentComp->Faction == TargetFaction)
						{
							// 2차 필터링: 실제 거리 조건 충족 여부 확인 (수학적 제곱근 생략 최적화)
							float DistSq = FVector::DistSquaredXY(SearchPos, Actor->GetActorLocation());
							if (DistSq <= RadiusSq)
							{
								OutActors.Add(Actor);
							}
						}
					}
				}
			}
		}
	}
}

 

(4) AI 타겟팅과 NPC 시스템

// 1. 유닛의 전투 AI 타겟팅 예시
void AMyBattleMonster::LookForTarget()
{
	auto* GridSys = GetGameInstance()->GetSubsystem<USimulationGridSubsystem>();
	if (!GridSys) return;

	TArray<AActor*> Candidates;
	// 전수 조사를 하지 않고, 내 주변 사거리(300cm) 내의 "적군 몬스터"만 가상 격자에서 수집
	GridSys->GetNearbyActorsByFaction(GetActorLocation(), 300.0f, EAgentFaction::EnemyMonster, Candidates);

	AActor* BestTarget = nullptr;
	float ClosestDistSq = BIG_NUMBER;

	for (AActor* Candidate : Candidates)
	{
		float DistSq = FVector::DistSquared(GetActorLocation(), Candidate->GetActorLocation());
		if (DistSq < ClosestDistSq)
		{
			ClosestDistSq = DistSq;
			BestTarget = Candidate;
		}
	}

	if (BestTarget)
	{
		CurrentCombatTarget = BestTarget; // 최적의 타겟 록온
	}
}

// 2.NPC의 가장 가까운 비어있는 일터(Workstation) 탐색 예시
void AMyTownNPC::FindNearestWorkstation()
{
	auto* GridSys = GetGameInstance()->GetSubsystem<USimulationGridSubsystem>();
	if (!GridSys) return;

	TArray<AActor*> Workstations;
	// 마을 전체의 수천 개 오브젝트를 뒤지지 않고, 내 활동 반경(2000cm) 내의 작업대만 수집
	GridSys->GetNearbyActorsByFaction(GetActorLocation(), 2000.0f, EAgentFaction::Workstation, Workstations);

	for (AActor* Bench : Workstations)
	{
		// 작업대 스탯을 확인해 비어있는지 체크 후 이동 처리...
	}
}

 

순서가 상관없는 배열에 최적화된 "RemoveSingleSwap"을 활용한 것을 볼 수 있다. 배열에서 객체를 삭제할 때 일반 Remove를 사용하면 뒤쪽 메모리가 당겨지면서 O(N)의 비용이 들지만, RemoveSingleSwap은 삭제된 자리에 맨 뒤의 원소를 집어넣어 O(1)로 연산이 종료된다.

 

탑다운 쿼터뷰임을 가정하여 DistSquaredXY 연산을 했다. 높이 Z값을 제외한 XY 평면 거리의 제곱만 비교했다. FVector::Dist는 무거운 제곱근 연산이기 때문에, XY 평면 거리의 제곱반 비교하는 연산으로 CPU 부하를 한 번 더 덜어낸 모습이다.

 

(추가 학습 : 에이전트 가상화 or 데이터-액터 디커플링)

 

(1) Data 정의: 가벼운 C++ 구조체로 NPC의 정보만 담을 그릇을 만든다. 화면 밖에서도 NPC가 던전 사냥을 하거나 자원을 캐야 하므로, 상태를 기억할 최소한의 데이터만 정의하는 것이다.

USTRUCT(BlueprintType)
struct FNPCAgentData
{
	GENERATED_BODY()

	UPROPERTY()
	int32 AgentID;

	UPROPERTY()
	FString AgentName;

	// 화면 밖에서도 시뮬레이션하기 위한 가상 좌표 (3D 액터 좌표 대신 사용)
	UPROPERTY()
	FVector2D VirtualPosition;

	// 현재 수행 중인 임무 상태
	UPROPERTY()
	FGameplayTag CurrentTaskTag; // 예: Task.Dungeon.Hunting, Task.Factory.Mining

	UPROPERTY()
	float CurrentHealth;

	// 자원 수집용 데이터
	UPROPERTY()
	int32 GatheredGold;

	// 현재 월드에 육체(AActor)가 스폰되어 있는지 여부
	UPROPERTY()
	TWeakObjectPtr<AActor> PhysicalBodyActor;
};

 

(2) 가상 시뮬레이션 서브시스템(UWorldAgentSimulationSubsystem) : 월드의 모든 NPC 데이터를 배열로 들고 관리한다. 매 프레임 돌지 않고 1초에 한 번씩만 루프를 돌며 화면 밖 NPC들의 사냥 및 자원 획득 연산을 수학 공식으로만 처리한다.

UCLASS()
class UNREALDESIGNPATTERN_API UWorldAgentSimulationSubsystem : public UGameInstanceSubsystem
{
	GENERATED_BODY()

public:
	// 1초에 한 번씩 호출되는 가상 시뮬레이션 틱 (CPU 부하 극최소화)
	void TickInvisibleAgents(float DeltaTime)
	{
		for (FNPCAgentData& Agent : AllAgents)
		{
			// 육체(Actor)가 없는 = 화면 밖 유닛들만 필터링하여 순수 연산 시뮬레이션
			if (!Agent.PhysicalBodyActor.IsValid())
			{
				SimulateOffScreenLogic(Agent, DeltaTime);
			}
		}
	}

private:
	UPROPERTY()
	TArray<FNPCAgentData> AllAgents;

	void SimulateOffScreenLogic(FNPCAgentData& Agent, float DeltaTime)
	{
		// 1. 던전 파견 중이라면?
		if (Agent.CurrentTaskTag.MatchesTag(FGameplayTag::RequestGameplayTag(TEXT("Task.Dungeon.Hunting"))))
		{
			// 순수 수학적 계산: 1초당 골드 획득, 체력 차감
			Agent.GatheredGold += 10;
			Agent.CurrentHealth -= 1.0f;
			
			// 가상 좌표 이동 (던전 내부 좌표계로 이동 처리)
			Agent.VirtualPosition += FVector2D(10.0f, 0.0f);
		}
	}
};

 

(3) 이제 화면 경계선에서 액터를 켜고 끄는 타이밍을 잡아야한다. 이때 지금까지 배웠던 공간 분할 격자 시스템을 사용하게 된다.

1) 카메라 가시 영역 체크 : 탑다운 뷰 특성에 맞게 맵의 사각형 범위를 계산한다.

2) 격자 검색 : 플레이어 카메라 범위 주변의 가상 격자 칸만 스캔한다.

3) 스위칭 로직 :

격자 안에 NPC 데이터가 들어왔는데 PhysicalBodyActor가 없다면 -> 오브젝트 풀에서 AActor를 꺼내서 해당 위치에 스폰하고 데이터 값을 동기화해준다(Materialize).

만약 NPC 가상 좌표가 카메라 범위를 벗어났다면 -> AActor에 담겨있던 최종 스탯을 구조체 데이터에 백업한 뒤, AActor만 풀에 반납하고 지워버린다.(Virtualize).


*개인 프로젝트 및 개인 공부*

 

1. 체력 시스템 — UHealthComponent

1-1. UHealthComponent C++ 클래스 만들기

1-2. 플레이어에 HealthComp 붙이기

 

2. 몬스터 만들기 — AMonster

2-1. AMonster C++ 클래스

2-2. BP_Monster 만들고 레벨에 배치

 

3. 몬스터 피격 처리 — UMonsterAnim + UMonsterAI

3-1. UMonsterAnim

3-2. UMonsterAI

3-3. AMonster에 MonsterAI 붙이기

3-4. 몬스터 AnimBP + 피격/사망 몽타주

 

4. 발사 → 몬스터 데미지 연결

4-1. PIE 테스트 — 몬스터 사냥

 

5. 이펙트 — 총구 머즐 플래시 + 임팩트

5-1. Niagara 모듈 추가

5-2. 머즐 컴포넌트 + 임팩트 슬롯

5-3. FireGun에 이펙트 추가

5-4. (사전) 무료 이펙트 팩 다운로드, BP에서 이펙트 에셋 할당 + 소켓 확인

5-5. PIE — 발사 연출

 

6. 플레이어 애니메이션 몽타주

6-1. UPlayerAnimInstance

6-2. 플레이어 몽타주 브리지 + 피격 함수 + 테스트 키

6-3. ABP_Player + 몽타주

 

7. 최종 PIE 테스트



*오늘의 총평*

강의를 듣고, 복습하고, 따라하는 시간이었다. 알차게 학습한 것 같다.

'TIL' 카테고리의 다른 글

07.02 TIL  (0) 2026.07.02
07.01 TIL  (0) 2026.07.01
06.29 TIL  (0) 2026.06.29
06.26 TIL  (0) 2026.06.26
06.25 TIL  (0) 2026.06.25