Sparta Unreal 부트캠프 81일차

정찬호·2026년 3월 27일

TIL - 2026.03.27

오늘 학습한 것
1. 코딩 테스트 - 택배 상자
2. 과제 10번 — 모듈을 플러그인으로 업그레이드
3. Challenge 분반 — 소켓 기반 히트박스 구현
4. TA 분반 — 머티리얼 파라미터 컬렉션(MPC)으로 림라이트 제어


1. 코딩 테스트 — 프로그래머스 택배 상자

문제 분석

  • 컨테이너 벨트는 한 방향으로만 흐르며 순서대로 상자를 내릴 수 있다.
  • 배송 순서와 컨테이너 벨트에 올리는 순서가 다르다.
  • 보조 컨테이너 벨트는 앞뒤로 이동 가능하지만 입구에서만 뺄 수 있다stack을 사용한다.
  • 보조 컨테이너를 써도 트럭의 요구 순서를 맞출 수 없다면 즉시 종료한다.

처음에는 메인 벨트에 queue가 필요할까 싶었는데, 순서대로 넣었다 빼는 구조라 반복문으로 인덱스를 관리하면 충분하다는 생각이 들었습니다.

풀이 흐름

현재 확인해야 하는 order 요소에 맞춰,
현재 박스 번호가 그것보다 작으면 미뤄둬야 하니 subBelt(스택)에 집어넣고,
같거나 더 크면 subBelt와 현재 박스 번호를 확인합니다.

과거 코드 vs 현재 코드

예전에 풀었던 코드는 while 내부에서 경우를 모두 처리하는 구조였습니다.

// 예전 코드
int solution(vector<int> order) {
    int answer = 0;
    int nowIdx = 1;
    bool impossible = true;
    stack<int> st;

    for (int idx : order)
    {
        impossible = true;
        while (nowIdx <= order.size() + 1)
        {
            if (nowIdx == idx)
            {
                nowIdx++; answer++; impossible = false; break;
            }
            else if ((!st.empty()) && st.top() == idx)
            {
                impossible = false; answer++; st.pop(); break;
            }
            else
            {
                st.push(nowIdx++);
            }
        }
        if (impossible) break;
    }
    return answer;
}

현재 코드는 관심사를 분리해서 훨씬 읽기 쉬워졌습니다. while로 먼저 필요한 만큼 subBelt에 밀어 넣고, if-else로 경우를 명확히 나눕니다.

// 현재 코드
int solution(vector<int> order) {
    int answer = 0;
    int nowIdx = 1;
    stack<int> subBelt;

    for (int currentOrder : order)
    {
        // currentOrder보다 작은 번호는 모두 subBelt로 밀어 넣기
        while (nowIdx < currentOrder)
        {
            subBelt.push(nowIdx++);
        }

        if (nowIdx == currentOrder)
        {
            // 메인 벨트에서 바로 실을 수 있는 경우
            answer++;
            nowIdx++;
        }
        else if (subBelt.top() == currentOrder)
        {
            // subBelt 맨 위에 원하는 상자가 있는 경우
            answer++;
            subBelt.pop();
        }
        else
        {
            // 어디에도 없으면 종료
            break;
        }
    }
    return answer;
}

2. 과제 10번 — 모듈을 플러그인으로 업그레이드

Module vs Plugin

어제 만들었던 MySpartaLog 모듈을 플러그인으로 업그레이드했습니다. 둘의 차이를 정리하면 아래와 같습니다.

구분ModulePlugin
정의C++ 코드들의 기능별 최소 단위모듈, 에셋, 설정을 묶은 패키지
목적프로젝트 내 코드 구조화, 빌드 최적화기능 재사용, 다른 프로젝트 이식
구성 요소Build.cs, 헤더, 소스.uplugin, 최소 하나의 모듈, Content
이식성프로젝트 종속프로젝트별 이식 가능

빠르게 빌드되어야 하면 Module, 이식 및 재사용이 우선이면 Plugin이 적합합니다. Module로 기능을 빠르게 만들고, Plugin으로 업그레이드해서 에셋과 컨텐츠를 추가하는 것이 좋은 흐름인 것 같다고 생각합니다.

MyNBCLog 플러그인 생성 절차

프로젝트 폴더/
└── Plugins/
    └── MyNBCLog/
        ├── MyNBCLog.uplugin
        └── Source/
            └── MySpartaLog/   ← 기존 모듈 폴더 이동
  1. 프로젝트 폴더 > 새 폴더 Plugins
  2. Plugins > 새 폴더 MyNBCLog
  3. MyNBCLog > 새 파일 MyNBCLog.uplugin
  4. MyNBCLog > 새 폴더 Source
  5. 프로젝트 Source에서 MySpartaLog 폴더 통째로 Source로 이동
  6. MyNBCLog.uplugin 내용 작성
{
    "FileVersion": 3,
    "Version": 1,
    "VersionName": "1.0",
    "FriendlyName": "My NBC Log Plugin",
    "Description": "과제 10 교안용 로그 플러그인",
    "Category": "Sparta",
    "CreatedBy": "Eunil",
    "CanContainContent": true,
    "Modules": [
        {
            "Name": "MySpartaLog",
            "Type": "Runtime",
            "LoadingPhase": "Default"
        }
    ]
}

.uplugin 파일 뜯어보기

직접 타이핑하다 보니 각 항목이 무슨 역할인지 궁금해져서 조사를 해보았습니다.
.uplugin 파일은 언리얼 엔진이 플러그인을 인식하고 어떤 환경에서 어떻게 로드할지 결정하는 명세서 역할을 한다고 합니다.

1. 기본 메타데이터 (플러그인 브라우저에 표시되는 정보)

설명
FileVersion플러그인 파일 포맷 버전. UE4·5 모두 3이 표준
Version개발자가 관리하는 정수형 버전 번호 (1, 2, 3...)
VersionName사용자에게 보여줄 버전 문자열 (예: "1.0-beta")
FriendlyName에디터 목록에 표시될 실제 이름
Description이 플러그인이 무엇을 하는지 설명
Category에디터 좌측 트리 메뉴에서 묶일 카테고리
CreatedBy제작자 이름

2. CanContainContent

매우 중요한 설정이다.

  • true: 플러그인 폴더 안에 Content 폴더를 가질 수 있으며 블루프린트, 스태틱 메시, 텍스처 등의 에셋을 포함할 수 있습니다.
  • false: 오직 C++ 코드(모듈)만 포함하는 순수 코드 플러그인일 때 사용합니다.

3. Modules 배열 — 모듈 설정

설명
Name모듈명폴더명 및 .Build.cs 파일명과 일치해야 한다
TypeRuntime게임 실행 시와 에디터 모드 모두에서 사용 (가장 일반적)
Editor언리얼 에디터가 켜져 있을 때만 로드 (툴 제작용)
DeveloperTool개발 중 디버그·테스트 도구
LoadingPhaseDefault엔진의 일반적인 로드 시점
PreDefault다른 모듈보다 조금 더 일찍 로드
PostConfigInit설정 파일 로드 직후 아주 초기 단계 (로그 시스템 등)

.uplugin 작성이 중요한 이유

언리얼 엔진은 프로젝트 Source 폴더뿐만 아니라 플러그인 폴더 내의 .uplugin 파일을 스캔하여 기능을 확장합니다. 나중에 이 플러그인 폴더만 복사해서 다른 프로젝트의 Plugins 폴더에 넣기만 해도 MySpartaLog의 기능을 그대로 재사용할 수 있습니다. 이것이 모듈을 플러그인으로 만드는 가장 큰 이유입니다.

플러그인 간의 연결 (의존성)

플러그인 간의 연결은 "이 플러그인이 작동하려면 저 플러그인이 반드시 먼저 켜져 있어야 해!" 라는 약속을 하는 과정입니다. 이를 플러그인 의존성(Dependency) 설정이라고 합니다.

연결은 크게 두 단계로 나뉩니다.

1단계 — .uplugin 파일에 의존성 등록

{
    "FileVersion": 3,
    "Version": 1,
    "Modules": [ "..." ],
    "Plugins": [
        {
            "Name": "OtherPluginName",
            "Enabled": true
        }
    ]
}

2단계 — Build.cs에 모듈 등록

플러그인 이름과 모듈 이름이 다를 수 있으니 주의해야 합니다.

public class MySpartaLog : ModuleRules
{
    public MySpartaLog(ReadOnlyTargetRules Target) : base(Target)
    {
        PublicDependencyModuleNames.AddRange(new string[] {
            "Core", "CoreUObject", "Engine",
            "OtherPluginModule"  // 상대 플러그인의 모듈 이름
        });
    }
}

연결 유형에 따른 처리 방법

상황해결 방법
강한 결합 (Hard Dependency).upluginBuild.cs에 모두 등록. 상대가 없으면 내 플러그인도 컴파일되지 않는다.
약한 결합 (Soft Dependency)Build.cs에는 넣지 않고 코드에서 FModuleManager::Get().IsModuleLoaded()로 체크하며 사용한다.

3. Challenge 분반 — 소켓 기반 히트박스 구현

이번 분반 목표

히트박스를 구현하고, 추후 회피(구르기) 구현을 진행할 예정입니다. 구르기 모션은 애니메이션에서 이동 위치까지 포함되어 있어 RootMotion 사용 여부를 결정해야 하는데, 관련 처리는 추후 세션에서 같이 작업할 예정이라고 합니다.

기존 피격 처리의 한계

void UGA_AttackBase::PerformMeleeTrace()
{
    AActor* AvatarActor = GetAvatarActorFromActorInfo();

    FVector Start = AvatarActor->GetActorLocation();
    FVector Forward = AvatarActor->GetActorForwardVector();
    FVector End = Start + Forward * TraceDistance;

    TArray<FHitResult> HitResults;
    GetWorld()->SweepMultiByObjectType(
        HitResults, Start, End, FQuat::Identity,
        FCollisionObjectQueryParams(ECC_Pawn),
        FCollisionShape::MakeSphere(TraceRadius),
        QueryParams
    );

    for (const FHitResult& Hit : HitResults)
        ApplyDamageToActor(Hit.GetActor());
}
문제내용
위치 부정확캐릭터 중심 기준이라 공격 위치와 충돌 위치가 맞지 않는 과한 충돌 범위
타이밍 부정확GameplayEvent 발생 시 한 번만 Trace하기 때문에 애니메이션 중간 피격 감지 불가
중복 타격중복 방지가 없어 같은 적에게 여러 번 데미지가 적용될 수 있음

해결 방안

문제해결책
위치 부정확Socket 기반 Trace
중복 HitTSet으로 중복 방지
타이밍 부정확AnimNotifyState로 Trace Window

Socket이란?

Socket은 Skeletal Mesh의 특정 위치에 붙는 앵커 포인트입니다. 스켈레탈 메쉬에서 각 본은 이름으로 매핑되어 있으며, 내부에 소켓을 추가하고 본과 소켓 기준으로 메쉬나 트레이스를 배치할 수 있습니다.

Socket의 장점:

  • 애니메이션과 자동 동기화: 뼈가 움직이면 Socket도 함께 움직입니다
  • 정확한 위치: 주먹/발 위치를 정확히 Trace할 수 있습니다
  • 재사용 가능: 콤보별로 다른 Socket을 지정해 사용할 수 있습니다

Project Settings → Engine → Collision에서 Object Channel / Trace Channel을 추가해 커스텀 충돌 타입을 정의할 수 있습니다.

코드 변경: Socket 기반 Trace

소켓 사용을 위한 변수와 캐시 함수 추가:

/** Trace할 Socket 이름 (hand_l, hand_r, foot_r 등) */
UPROPERTY(EditDefaultsOnly, Category = "Attack|Trace")
FName TraceSocketName = FName("hand_r");

/** 캐릭터 Mesh 캐시 (BeginPlay에서 설정) */
UPROPERTY()
TObjectPtr<USkeletalMeshComponent> CachedMesh;

void CacheMeshComponent();
void UGA_AttackBase::CacheMeshComponent()
{
    if (CachedMesh) return;  // 이미 캐시됨

    AActor* AvatarActor = GetAvatarActorFromActorInfo();
    if (!AvatarActor) return;

    if (ACharacter* Character = Cast<ACharacter>(AvatarActor))
        CachedMesh = Character->GetMesh();
}

변경된 PerformMeleeTrace:

void UGA_AttackBase::PerformMeleeTrace()
{
    AActor* AvatarActor = GetAvatarActorFromActorInfo();
    if (!AvatarActor) return;

    CacheMeshComponent();

    FVector TraceLocation;
    if (CachedMesh && TraceSocketName != NAME_None)
    {
        // Socket 위치 사용
        TraceLocation = CachedMesh->GetSocketLocation(TraceSocketName);
    }
    else
    {
        TraceLocation = AvatarActor->GetActorLocation()
            + AvatarActor->GetActorForwardVector() * TraceDistance;
    }

    FCollisionQueryParams Params;
    Params.AddIgnoredActor(AvatarActor);

    TArray<FHitResult> HitResults;
    bool bHit = GetWorld()->SweepMultiByChannel(
        HitResults,
        TraceLocation,
        TraceLocation + FVector(1.f, 0.f, 0.f),  // 미세한 이동
        FQuat::Identity,
        ECC_Pawn,
        FCollisionShape::MakeSphere(TraceRadius),
        Params
    );

    // 디버그 시각화
    if (bShowDebug)
    {
        FColor Color = bHit ? FColor::Red : FColor::Green;
        DrawDebugSphere(GetWorld(), TraceLocation, TraceRadius, 12, Color, false, 0.1f);
    }

    if (bHit)
    {
        for (const FHitResult& Hit : HitResults)
        {
            AActor* HitActor = Hit.GetActor();
            if (!HitActor) continue;
            ApplyDamageToActor(HitActor);
        }
    }
}

GA_ComboAttackBase 상속 구조 개선

GA_AttackBaseGA_ComboAttackBase가 완전히 분리되어 있었는데, GA_ComboAttackBaseGA_AttackBase를 상속하도록 수정했습니다. 덕분에 중복되는 몽타주 변수를 제거하고 변수명을 부모의 것으로 통일할 수 있었습니다.

AnimNotifyState로 Trace Window 구현

기존 방식 (Gameplay Event로 한 번만 Trace)의 한계:

  • Event 전에 적이 범위에 있어도 Miss
  • Event 후에 적이 범위에 들어와도 Miss
  • 빠른 공격에서 타이밍 맞추기 어려움

AnimNotifyState를 사용해 Begin부터 End까지 매 지정된 시간마다 Trace를 시행하면 다음과 같은 장점을 얻습니다:

  • 애니메이션과 정확히 동기화
  • 적이 구간 내 언제든 들어오면 Hit
  • 타이밍 문제 해결

이로써 역할이 명확히 분리됐습니다:

  • GA (Ability): 어떤 Socket을 사용할지 설정
  • AnimNotifyState: 언제 Trace를 시작/종료할지만 알려준다. Notify는 Socket을 몰라도 된다. GA가 이미 알고 있다.

콤보 어택 몽타주에도 AnimNotifyState만 추가하면 된다. 이미 GA_AttackBase에서 Socket이 설정되었으며, GA_ComboAttackBase는 이를 상속했기 때문입니다.

핵심 정리

항목기존 방식수정된 방식
Trace 위치캐릭터 중심Socket 위치 (정확)
중복 방지없음TSet 기반
타이밍단일 시점 EventAnimNotifyState Window
호출 빈도1회Window 동안 반복

4. TA 분반 — 머티리얼 파라미터 컬렉션(MPC)으로 림라이트 제어

나나이트 관련 복습 메모

나나이트는 로우 폴리 메쉬에서 성능 저하가 발생할 수 있습니다. 컬링 처리 비용이 있어서 로우 폴리일 경우 절약되는 성능보다 연산으로 소비되는 비용이 더 클 수 있습니다. (AI 영상에는 해당 부분이 빠져 있으니 주의)

문제 상황

PBR, TOON, PostProcess 모두에 RimLight가 구현되어 있는데, 이 RimLight 관련 수치를 각각 일일이 수정하는 것은 번거롭습니다. BP에서 일괄적으로 컨트롤하는 방법을 배웠습니다.

일반 Dynamic Material Instance 방식의 한계

보통 머티리얼 파라미터 값을 수정하려면:
1. 다이나믹 머티리얼 인스턴스로 만들기
2. 파라미터 이름으로 접근하기
3. 값 수정

이 과정을 거쳐야 합니다. 일반 머티리얼은 런타임에 파라미터 값 수정이 불가능하기 때문에 DynamicMaterialInstance(동적 객체)로 변환해주어야 합니다. 이 방식은 객체 단위 전달에는 용이하지만, 전역적으로 여러 머티리얼에 동시에 값을 전달하기엔 불편합니다.

💡 파라미터 생성 시 선형 컬러와 일반 컬러 두 가지 종류가 있는데, 머티리얼 에디터에서는 선형 컬러를 사용하기 때문에 선형 컬러를 사용해야 합니다.

머티리얼 파라미터 컬렉션 (MPC)

MPC는 일종의 데이터 테이블이라고 생각하면 됩니다. 파라미터를 미리 정의해서 담고 있으며, 런타임에 MPC의 인스턴스를 생성해서 레벨에 업로드하고 그 인스턴스의 값을 수정하는 방식입니다. MPC는 ScalarVector 타입만 사용 가능합니다.

머티리얼 에디터에서 CollectionParameter 노드를 사용하면 MPC의 파라미터 값을 가져올 수 있습니다.

MPC 사용 시 주의사항

머티리얼의 포인터를 끌어다 놓으면 MPC의 SetVectorParameterValue를 가져올 수 없습니다. 함수의 포인터를 끌어다 놓고 검색했을 때 나오는 Material SetVectorParameterValue가 MPC의 것입니다.

BP에서 연결하면 아래와 같이 Collection과 Parameter Name을 지정할 수 있습니다.

또한 일반 BP에서 Get을 하면 인스턴스의 것이 아닌 MPC 에셋 자체의 값을 가져오는 문제가 있습니다. MPC 에셋 자체는 데이터 구조체처럼 미리 할당해 놓는 값이고, 실행 시 레벨에 인스턴스가 생성되어 그 인스턴스의 값이 공유 및 수정됩니다.

MPC vs 일반 파라미터 비교

분류MPC (파라미터 컬렉션)일반 파라미터
데이터 위치별도 에셋(.uasset)으로 독립 저장머티리얼 인스턴스 내부에 저장
다른 머티리얼과 공유가능 — 여러 머티리얼이 동일 MPC 참조불가 — 해당 머티리얼/인스턴스 전용
지원 타입Scalar, Vector만 지원Scalar, Vector, Texture, Static Switch 등 모두

환경, 시간대 등 전역적으로 여러 머티리얼에 동시에 영향을 주는 변수 컨트롤에는 MPC가 더 적합합니다.

에디터 유틸리티 위젯 연동

이전에 구현한 에디터 유틸리티 위젯에서 값을 지정하게 하고, 값이 변경되면 바로 MPC에 Set해주는 방식으로 설정된 값을 실시간으로 확인할 수 있게 만들었습니다.

  • SinglePropertyView: 유틸리티 위젯에서 파라미터에 따라 위젯이 적용되도록 사용할 수 있습니다. 위젯 처음 설정 시에는 정의되지 않은 오브젝트로 나타나며, 정의 시에는 어떤 객체인지 표시됩니다.
  • BP_RimLight: MPC_RimLight에 값을 설정하는 역할을 하는 액터
  • MPC의 특징: 에셋 자체를 참조하지만 BP에서 수정 시 런타임 인스턴스가 변경되므로, 이를 활용하면 레벨마다 다른 환경을 구축할 수 있습니다.

기타 — 위젯 리플렉터 & 아틀라스

  • 툴 → 디버그 → 위젯 리플렉터: UI 위젯 구조를 디버깅할 수 있는 툴
  • 아틀라스 → 텍스처 아틀라스 표시: 아틀라스 창에서 언리얼에 저장된 각 아이콘의 키 값을 확인할 수 있습니다.

💡 오늘의 핵심 교훈

플러그인 관련

  • .uplugin은 플러그인의 명세서다. 이 파일 하나로 엔진이 플러그인을 인식하고 로드 순서와 환경을 결정합니다.
  • 플러그인 폴더 복사만으로 다른 프로젝트에 이식할 수 있다. 이것이 모듈 → 플러그인 업그레이드의 핵심 이유입니다.
  • 의존성은 .upluginBuild.cs 두 곳 모두에 등록해야 합니다. 플러그인 이름과 모듈 이름이 다를 수 있으니 주의!

히트박스 관련

  • Socket 기반 Trace가 정확하다. 캐릭터 중심 기반 Trace는 공격 위치와 충돌 위치가 맞지 않아 과도한 충돌 범위가 생깁니다.
  • AnimNotifyState로 Trace Window를 만들면 타이밍 문제가 해결된다. 단일 Event Trace는 프레임 타이밍에 민감합니다.
  • 역할 분리가 중요하다. GA는 "어떤 Socket을 쓸지", AnimNotifyState는 "언제 Trace할지"만 담당하게 하면 유지보수가 쉬워집니다.

MPC 관련

  • MPC는 전역 머티리얼 파라미터 관리에 적합하다. 여러 머티리얼에 동시에 영향을 줘야 할 때 Dynamic Material Instance보다 훨씬 편리합니다.
  • SetVectorParameterValue는 함수 포인터에서 끌어다 놔야 MPC의 것이 나온다. 머티리얼 포인터에서 끌어다 놓으면 찾을 수 없습니다.
  • MPC의 Get은 에셋 값이 아닌 런타임 인스턴스 값을 반환한다. 에셋 자체 값과 런타임 인스턴스 값의 차이를 인지해야 합니다.
profile
게임 개발 지망생입니다.

0개의 댓글